Problem/Motivation

Found in #3086374: Make Drupal 8 & 9 compatible with PHP 7.4.

Usage of a mix of arrays and Attribute objects for $variable['attributes'] for rendering has some problems.

1. Sometimes $variables['attributes'] is an Attribute object, and array functions such as array_key_exists do not operate properly on them.

@alexpott

+++ b/core/includes/theme.inc
@@ -817,9 +817,9 @@ function template_preprocess_image(&$variables) {
-      // If the property has already been defined in the attributes,
-      // do not override, including NULL.
-      if (array_key_exists($key, $variables['attributes'])) {
+      // If the property has already been defined in the attributes, do not
+      // override, including NULL. Attributes can be an array or an object.
+      if ((is_array($variables['attributes']) && array_key_exists($key, $variables['attributes'])) || (is_object($variables['attributes']) && property_exists($variables['attributes'], $key))) {
         continue;

This fix isn't right unfortunately.

>>> $a = new \Drupal\Core\Template\Attribute(['foo' => NULL, 'bar' => 'bar']);
=> Drupal\Core\Template\Attribute {#4861
     markup: " bar="bar"",
   }
>>> $a->toArray();
=> [
     "foo" => null,
     "bar" => "bar",
   ]
>>> property_exists($a, 'foo');
=> false
>>> property_exists($a, 'bar');
=> false
>>> isset($a['foo']);
=> true
>>> isset($a['bar']);
=> true
>>> isset($a['width']);
=> false
>>> isset($a->toArray()['foo']);
=> false

So I think this should be we need to detect if we dealing with an array() or an Attribute object. And if we're dealing with an attribute object use isset() - which is super funky because it works different from arrays. Anyhow I think this is a good candidate for splitting out into a separate issue and getting test coverage as the current code in HEAD is broken too...

>>> array_key_exists('foo', $a)
=> false
>>> array_key_exists('bar', $a)
=> false

lolz.

2. Merging attributes is tricky e.g. $variables['attributes'] = NestedArray::mergeDeep($variables['attributes'], $variables[$key]['#attributes']);

@Charlie ChX Negyesi:

mergeDeep is a special kind of trouble. It passes arguments to mergeDeepArrays which doesn't fatal on as long as the "array" is iterable but if the iterable contains ArrayAccess objects instead of arrays then the recursion breaks! [...]. I mean... https://3v4l.org/X5Cje

Proposed resolution

Add new methods to the Attribute object for merging and determining if it has an attribute.
Add a helper for themers to use in preprocess functions where you don't know if you're dealing with an attributes object or an array.

Remaining tasks

User interface changes

None

API changes

See the change record: https://www.drupal.org/node/3110716

Data model changes

None

Release notes snippet

In theme preprocess functions the attributes property is sometimes an array and sometimes an Attribute object. The \Drupal\Core\Template\AttributeHelper class has been added to help deal with this situation. It has methods to check for the existence of an attribute in either an object or an array and to merge two attribute collections together irrespective of type. See https://www.drupal.org/node/3110716 for more information.

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

mondrake created an issue. See original summary.

mondrake’s picture

Issue summary: View changes
mondrake’s picture

Status: Active » Needs review
FileSize
2.25 KB

Here's a proposal for point 1, building on @alexpott's suggestion in #3086374-110: Make Drupal 8 & 9 compatible with PHP 7.4.

mondrake’s picture

Assigned: Unassigned » mondrake

Working on point 2.

mondrake’s picture

Assigned: mondrake » Unassigned
FileSize
5.76 KB

Addressed point 2 with a bespoke copy of NestedArray::mergeDeep and ::mergeDeepArray. Will always return attributes as an array.

mondrake’s picture

sjerdo’s picture

Instead of only supporting 'real'(non-object) arrays and instances of Attribute, shouldn't we additionally allow all instances of ArrayAcces?

alexpott’s picture

Issue summary: View changes
  1. +++ b/core/lib/Drupal/Core/Template/Attribute.php
    @@ -356,4 +356,78 @@ public function jsonSerialize() {
    +  /**
    +   * Returns an attribute object or array always as an array.
    +   *
    +   * @param \Drupal\Core\Template\Attribute|array $attribute
    +   *   The Attribute object or an array of attributes.
    +   *
    +   * @return array
    +   *   The attributes as an array.
    +   */
    +  public static function getAsArray($attribute) {
    

    This is an odd method.

  2. +++ b/core/lib/Drupal/Core/Template/Attribute.php
    @@ -356,4 +356,78 @@ public function jsonSerialize() {
    +   * @param array ...
    +   *   Attributes to merge.
    

    We support varidics now... see https://www.php.net/manual/en/migration56.new-features.php

  3. +++ b/core/lib/Drupal/Core/Template/Attribute.php
    @@ -356,4 +356,78 @@ public function jsonSerialize() {
    +   * @return \Drupal\Core\Template\Attribute|array
    +   *   The merged attributes.
    +   */
    +  public static function mergeDeep() {
    

    How can this return an Attribute object?

  4. +++ b/core/lib/Drupal/Core/Template/Attribute.php
    @@ -356,4 +356,78 @@ public function jsonSerialize() {
    +   * @return array
    +   *   The merged attributes, as an array.
    +   */
    +  public static function mergeDeepAttributes(array $attributes, $preserve_integer_keys = FALSE) {
    

    This returning an array feels quite odd when its on the Attributes object.

amateescu’s picture

Status: Needs review » Needs work

Instead of the two new methods on the Attribute class, I think we should do a simple ternary assignment like $attributes = $variables['attributes'] instanceof Attribute ? $variables['attributes']->toArray() : $variables['attributes']; in those three places.

mondrake’s picture

Thanks for the reviews.

Reshuffled.

1) introduced getNames in place of getAsArray, will only return the list of attributes' names
2) the merge method does the merging of two attributes A and B (either arrays or objects), preserving the type of attribute A

Interdiff not very useful.

mondrake’s picture

Status: Needs work » Needs review
andypost’s picture

Looks ready to go, could use follow-up to deprecate array argument types and to find a way to clean-up usage

andralex’s picture

Status: Needs review » Reviewed & tested by the community

Tested the patch #10. It works for me. The only concern is about coding standards.

+++ b/core/lib/Drupal/Core/Template/Attribute.php
@@ -356,4 +356,70 @@ public function jsonSerialize() {
+  public static function getnames($attribute) {

According to it, methods should use lowerCamel naming.

longwave’s picture

+++ b/core/lib/Drupal/Core/Template/Attribute.php
@@ -356,4 +356,70 @@ public function jsonSerialize() {
+   * Returns the keys of an Attribute object or array.

This is perhaps bikeshedding but why not getKeys instead of getNames?

+++ b/core/includes/theme.inc
@@ -819,7 +819,7 @@ function template_preprocess_image(&$variables) {
+      if (in_array($key, Attribute::getNames($variables['attributes']))) {

Or, this could become a keyExists() helper?

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
  1. +++ b/core/lib/Drupal/Core/Template/Attribute.php
    @@ -356,4 +356,70 @@ public function jsonSerialize() {
    +  public static function getnames($attribute) {
    

    +1 on renaming this. Or even changing the functionality to be hasKey or keyExists().

  2. +++ b/core/lib/Drupal/Core/Template/Attribute.php
    @@ -356,4 +356,70 @@ public function jsonSerialize() {
    +        // Otherwise, use the latter value, overriding any previous value.
    +        else {
    +          $result[$key] = $value;
    +        }
    

    It'd be good to have something other then arrays of classes in the tests to test this a bit more. For href or id or name - actually the name attribute is another reason why getnames() is badly named.

  3. +++ b/core/tests/Drupal/Tests/Core/Template/AttributeTest.php
    @@ -452,4 +452,31 @@ public function testStorage() {
    +    $this->expectException(\InvalidArgumentException::class);
    +    $this->expectExceptionMessage('Invalid attribute argument');
    +    Attribute::merge('not an array', new Attribute(['class' => ['class1']]));
    +    $this->expectException(\InvalidArgumentException::class);
    +    $this->expectExceptionMessage('Invalid attribute argument');
    +    Attribute::merge(new Attribute(['class' => ['example-class']]), 'not an array');
    

    This tests should be broken into separate tests. I'm not sure the second test is even running. Checked locally and I can change the second exception message to whatever I want and the test still passes.

andralex’s picture

Status: Needs work » Needs review
FileSize
7 KB

I've updated the patch from #10 according to the last comments.

andralex’s picture

FileSize
7.31 KB

Fixed coding standards issues in last patch.

alexpott’s picture

  1. +++ b/core/lib/Drupal/Core/Template/Attribute.php
    @@ -356,4 +356,73 @@ public function jsonSerialize() {
    +    if ($attributes instanceof static) {
    +      return in_array($key, array_keys($attributes->toArray()));
    +    }
    +    elseif (is_array($attributes)) {
    +      return in_array($key, array_keys($attributes));
    +    }
    +    throw new \InvalidArgumentException('Invalid attribute argument');
    

    in_array() is prone to strange results plus we're doing unnecessary work here. We can do this instead:

        if (!($attributes instanceof static || is_array($attributes))) {
          throw new \InvalidArgumentException('Invalid attribute argument');
        }
        return array_key_exists($key, $attributes instanceof static ? $attributes->storage : $attributes);
  2. +++ b/core/lib/Drupal/Core/Template/Attribute.php
    @@ -356,4 +356,73 @@ public function jsonSerialize() {
    +   * @param \Drupal\Core\Template\Attribute|array $attribute_a
    ...
    +   * @param \Drupal\Core\Template\Attribute|array $attribute_b
    

    Let's call these $a and $b - the prefix makes this harder to read because so much of the variable name is the same. Also we have precedence for this in core - see \Drupal\jsonapi\JsonApiResource\Link::merge() and \Drupal\Core\Render\BubbleableMetadata::mergeAttachments()

  3. +++ b/core/lib/Drupal/Core/Template/Attribute.php
    @@ -356,4 +356,73 @@ public function jsonSerialize() {
    +  public static function merge($attribute_a, $attribute_b) {
    +    if (!($attribute_a instanceof static || is_array($attribute_a)) || !($attribute_b instanceof static || is_array($attribute_b))) {
    +      throw new \InvalidArgumentException('Invalid attribute argument');
    +    }
    +
    +    $merge_a = $attribute_a instanceof static ? $attribute_a->toArray() : $attribute_a;
    +    $merge_b = $attribute_b instanceof static ? $attribute_b->toArray() : $attribute_b;
    +    $result = [];
    +    foreach ([$merge_a, $merge_b] as $attribute) {
    +      foreach ($attribute as $key => $value) {
    +        // Renumber integer keys as array_merge_recursive() does.
    +        if (is_int($key)) {
    +          $result[] = $value;
    +        }
    +        // Recurse when both values are arrays.
    +        elseif (isset($result[$key]) && is_array($result[$key]) && is_array($value)) {
    +          $result[$key] = self::merge($result[$key], $value);
    +        }
    +        // Otherwise, use the latter value, overriding any previous value.
    +        else {
    +          $result[$key] = $value;
    +        }
    +      }
    +    }
    +
    +    return $attribute_a instanceof static ? new static($result) : $result;
    +  }
    

    I don't think we should be duplicating logic from NestedArray here. We could do

      public static function merge($a, $b) {
        if (!($a instanceof static || is_array($a)) || !($b instanceof static || is_array($b))) {
          throw new \InvalidArgumentException('Invalid attribute argument');
        }
        $result = NestedArray::mergeDeep(
          $a instanceof static ? $a->toArray() : $a,
          $b instanceof static ? $b->toArray() : $b,
        );
    
        return $a instanceof static ? new static($result) : $result;
      }
    

    Because everything we're passing is an array we don;t run into the problem @chx mentions in the issue summary.

  4. +++ b/core/tests/Drupal/Tests/Core/Template/AttributeTest.php
    @@ -452,4 +452,47 @@ public function testStorage() {
    +    $this->expectException(\InvalidArgumentException::class);
    +    $this->expectExceptionMessage('Invalid attribute argument');
    +    Attribute::keyExists('class', 'not an array');
    

    For similar reasons let's move this to a separate method for exception testing.

  5. +++ b/core/tests/Drupal/Tests/Core/Template/AttributeTest.php
    @@ -452,4 +452,47 @@ public function testStorage() {
    +    $this->assertEquals(['class' => ['example-class', 'class1']], Attribute::merge(['class' => ['example-class']], ['class' => ['class1']]));
    +    $this->assertEquals(['class' => ['example-class', 'class1']], Attribute::merge(['class' => ['example-class']], new Attribute(['class' => ['class1']])));
    +    $this->assertEquals(new Attribute(['class' => ['example-class', 'class1']]), Attribute::merge(new Attribute(['class' => ['example-class']]), ['class' => ['class1']]));
    +    $this->assertEquals(new Attribute(['class' => ['example-class', 'class1']]), Attribute::merge(new Attribute(['class' => ['example-class']]), new Attribute(['class' => ['class1']])));
    

    It would be good to not only test class => array here... something like name or id.

mondrake’s picture

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

The more I think about this the more I am inclined to move away from static methods - just load the array into an Attribute object and use offsetExists for the existence check, and have a merge method with one argument only (the attribute object/array to be merged to current storage).

I'll work on a patch in that sense.

FWIW I was suggesting getNames because several methods on the class already use $name for the arguments, and according to RFC1866, para 3.2.4.,

An attribute specification typically consists of an attribute name, an equal sign, and a value, [...].

In fact, IMHO the Attribute object naming is not fully correct - it's more an AttributeCollection.

But yeah, that's more semantics.

alexpott’s picture

@mondrake I'm not sure that #19 will work. The problem is these methods should not change the type of the variable during template preprocessing.

Re getNames vs getKeys - your point is a very good one. If the documentation of the gertNames method made it clear that it was this "name" it was getting then it'd be better. Also - very very good point on Attribute vs Attribute collection. I''d love to see that re-name as part of #2664570: Move Attribute classes under Drupal\Component

mondrake’s picture

mondrake’s picture

Assigned: mondrake » Unassigned
Status: Needs work » Needs review
FileSize
7.34 KB

Thanks all for reviews and comments. Here's #19 in practice.

No interdiff, it won't make sense.

alexpott’s picture

+++ b/core/includes/theme.inc
@@ -1578,7 +1581,9 @@ function template_preprocess_field(&$variables, $hook) {
+    $attributes->merge((array) $element['#items'][0]->_attributes);

Why do we need to cast to an array?

mondrake’s picture

#23 dunno, was just moving that piece without questioning

EDIT - the typecasting is there since when that piece of code was introduced in #2214241: Field default markup - removing the divitis

mondrake’s picture

Let's see what happens if we remove the typecasting.

andralex’s picture

Status: Needs review » Reviewed & tested by the community

Looks good to go for me.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work

I'm sorry I was wrong. By removing the typecasting we're introducing a very subtle API change. Let's not do that. Especially when this issue is all about not making subtle API changes by converting between arrays and attribute objects.

mondrake’s picture

Status: Needs work » Reviewed & tested by the community
FileSize
7.34 KB

Reuploading #22 then.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
+++ b/core/includes/theme.inc
@@ -1243,7 +1244,9 @@ function template_preprocess(&$variables, $hook, $info) {
+      $attributes = $variables['attributes'] instanceof Attribute ? $variables['attributes'] : new Attribute($variables['attributes']);
+      $attributes->merge($variables[$key]['#attributes']);
+      $variables['attributes'] = $variables['attributes'] instanceof Attribute ? $attributes : $attributes->toArray();

So I was really surprised by the change from #17 to #22. I preferred the static method helpers that dealt with type for the developer. Here's why:
1. Looking at the above code that's a lot a theme developer to know about in order to use this method.
2. As we've seen preprocess functions can change an array to an attributes object and vice versa so less thinking anyone has to do the better.

Can we go back to #17 + the fixes asked for in #18?

mondrake’s picture

If we really want static helpers here, I'd rather add them to #22 then, so that they just deal with the type conversions, while we leave logic in the instance methods.

alexpott’s picture

@mondrake but then we’re going to have even more naming fun

mondrake’s picture

Status: Needs work » Needs review
FileSize
12.23 KB
11.93 KB

How about this, adding a AttributeHelper class for the static methods only. I see precendents of this in core. When attributes will no longer be arrays but only object instances, then the helper will no longer be needed.

alexpott’s picture

Status: Needs review » Needs work
Issue tags: +Needs change record

@mondrake it's not pretty :) but then again having to deal with either an array or an attribute object is not pretty either so I think the compromise of adding an AttributeHelper is a good one. I think we need a change record to tell developers that this helper exists and why it is good to use it.

  1. +++ b/core/includes/theme.inc
    @@ -821,7 +821,7 @@ function template_preprocess_image(&$variables) {
    -      if (array_key_exists($key, $variables['attributes'])) {
    +      if (AttributeHelper::attributeExists($key, $variables['attributes'])) {
    
    @@ -1243,7 +1243,7 @@ function template_preprocess(&$variables, $hook, $info) {
    -      $variables['attributes'] = NestedArray::mergeDeep($variables['attributes'], $variables[$key]['#attributes']);
    +      $variables['attributes'] = AttributeHelper::mergeCollections($variables['attributes'], $variables[$key]['#attributes']);
    
    @@ -1578,7 +1578,7 @@ function template_preprocess_field(&$variables, $hook) {
    -    $variables['attributes'] = NestedArray::mergeDeep($variables['attributes'], (array) $element['#items'][0]->_attributes);
    +    $variables['attributes'] = AttributeHelper::mergeCollections($variables['attributes'], (array) $element['#items'][0]->_attributes);
    

    This looks great from a developer experience POV.

  2. +++ b/core/lib/Drupal/Core/Template/Attribute.php
    @@ -208,6 +209,19 @@ public function setAttribute($attribute, $value) {
    +  public function hasAttribute($name) {
    
    @@ -356,4 +370,20 @@ public function jsonSerialize() {
    +  public function merge(Attribute $collection) {
    

    Having these methods here will be great if we ever get to the place where all #attributes properties in themes are Attribute objects. Nice.

mondrake’s picture

Status: Needs work » Needs review
Issue tags: -Needs change record
alexpott’s picture

Version: 8.9.x-dev » 8.8.x-dev
Issue summary: View changes
Status: Needs review » Reviewed & tested by the community

@mondrake the CR looks good thanks.

I've updated the issue summary with the solution and other required information.

This fix should target 8.8.x as we'd like to support PHP 7.4 on 8.8.x

  • catch committed f90ca9b on 9.0.x
    Issue #3090145 by mondrake, andralex, alexpott, amateescu, andypost,...

  • catch committed 106e16b on 8.9.x
    Issue #3090145 by mondrake, andralex, alexpott, amateescu, andypost,...
catch’s picture

Status: Reviewed & tested by the community » Patch (to be ported)

Committed f90ca9b and pushed to 9.0.x. Thanks!

Cherry-picked to 8.9.x. But leaving at 'to be ported' against 8.8.x for after packaging is fixed. I think adding the new method and class to a patch release is OK given this is a critical bug fix although would be nice to commit it just after rather than just before a patch release.

catch’s picture

Status: Patch (to be ported) » Fixed

8.8.2 is out now. Cherry-picked to 8.8.x

  • catch committed 12e99ab on 8.8.x
    Issue #3090145 by mondrake, andralex, alexpott, amateescu, andypost,...

Status: Fixed » Closed (fixed)

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