Problem/Motivation

When denormalizing fields, \Drupal\serialization\Normalizer\FieldNormalizer::denormalize() iterates over field items and calls \Drupal\serialization\Normalizer\FieldItemNormalizer::denormalize(), which does:

  public function denormalize($data, $class, $format = NULL, array $context = []) {
    …
    $field_item->setValue($this->constructValue($data, $context));
    …
  }

and then if we look at \Drupal\serialization\Normalizer\FieldItemNormalizer::constructValue():

  protected function constructValue($data, $context) {
    return $data;
  }

… we see that this assumes that the value for a property never needs denormalization, which is wrong!

This hard-blocks #2926507: Discourage @FieldType-level normalizers, encourage @DataType-level normalizers, to strengthen the API-First ecosystem and #2926508: Add DateTimeNormalizer+TimestampNormalizer, deprecate TimestampItemNormalizer: @DataType-level normalizers are reusable by JSON:API.

This issue introduces the essential infrastructure to make #2926507: Discourage @FieldType-level normalizers, encourage @DataType-level normalizers, to strengthen the API-First ecosystem possible.

Proposed resolution

  1. Automatically call ::denormalize()!
  2. Add test coverage similar to #2950486: Test coverage: custom modules' format-agnostic @FieldType and @DataType-level normalizers behavior in JSON API but also include test coverage for denormalization.

Remaining tasks

None!

User interface changes

None.

API changes

None.

Based on the issue title and summary, one would understandably think (or fear!) that this issue would introduce API changes. But that's actually not the case. The fact that zero tests need to change proves this! 🎉 We're only adding a new test. 💪

Data model changes

None.

CommentFileSizeAuthor
#44 2957385-44.patch22.64 KBWim Leers
#44 interdiff.txt1.26 KBWim Leers
#41 2957385-41.patch22.73 KBWim Leers
#41 interdiff.txt2.03 KBWim Leers
#40 2957385-40.patch22.31 KBWim Leers
#40 interdiff.txt3.54 KBWim Leers
#39 2957385-39.patch22.11 KBWim Leers
#39 interdiff.txt3.62 KBWim Leers
#36 2957385-36.patch21.79 KBWim Leers
#31 2957385-31.patch21.8 KBWim Leers
#28 2957385-28.patch21.84 KBWim Leers
#28 interdiff.txt2.33 KBWim Leers
#21 2957385-21.patch21.59 KBWim Leers
#21 interdiff.txt2.41 KBWim Leers
#20 2957385-20.patch21.39 KBWim Leers
#20 interdiff.txt10.02 KBWim Leers
#18 2957385-18.patch19.92 KBWim Leers
#18 interdiff.txt955 bytesWim Leers
#17 2957385-17.patch19.8 KBWim Leers
#17 interdiff.txt4.08 KBWim Leers
#13 2957385-12.patch19.24 KBWim Leers
#13 interdiff.txt2.31 KBWim Leers
#11 2957385-9-rerolled.patch17 KBWim Leers
#9 2957385-9.patch15.78 KBWim Leers
#9 2957385-9-tests_only.patch10.92 KBWim Leers
#8 2957385-7.patch6.13 KBWim Leers
#8 interdiff.txt2.47 KBWim Leers
#5 2957385-5.patch4.92 KBWim Leers
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

Wim Leers created an issue. See original summary.

Wim Leers’s picture

Title: FieldItemNormalizer never cals @DataType-level normalizer service' ::denormalize() method » FieldItemNormalizer never calls @DataType-level normalizer service' ::denormalize() method
Wim Leers’s picture

The JSON API sister issue for this has a green patch already. See #2955615: Field properties are not being denormalized. This issue should be able to adopt the same approach.

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.

Wim Leers’s picture

Assigned: Unassigned » Wim Leers
Status: Active » Needs review
FileSize
4.92 KB

#2926508: Add DateTimeNormalizer+TimestampNormalizer, deprecate TimestampItemNormalizer: @DataType-level normalizers are reusable by JSON:API has run into this. Here's a patch that adds support for it. If all is well, this will just pass tests on the first try 🤘

Status: Needs review » Needs work

The last submitted patch, 5: 2957385-5.patch, failed testing. View results

Wim Leers’s picture

Woah, very close, only EntityTestMapField(Json|HalJson)AnonTest failed and Drupal\Tests\serialization\Unit\Normalizer\EntityReferenceFieldItemNormalizerTest!

Wim Leers’s picture

Assigned: Wim Leers » Unassigned
Status: Needs work » Needs review
FileSize
2.47 KB
6.13 KB

Easy. One oversight that only manifests itself for @FieldType=map, and one mock.

This should be green!

Wim Leers’s picture

We probably want to add test coverage here.

Tests-only patch is also the interdiff.

Wim Leers’s picture

+++ b/core/modules/serialization/tests/modules/test_datatype_boolean_emoji_normalizer/src/Normalizer/BooleanNormalizer.php
@@ -0,0 +1,36 @@
+  public function normalize($object, $format = NULL, array $context = []) {
+    return $object->getValue() ? '👍' : '👎';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function denormalize($data, $class, $format = NULL, array $context = []) {
+    if (!in_array($data, ['👍', '👎'], TRUE)) {
+      throw new \UnexpectedValueException('Only 👍 and 👎 are acceptable values.');
+    }
+    return $data === '👍';
+  }

This may seem far-fetched, but it isn't really.

A concrete example of this can be found in the @DataType=timestamp field, where a UNIX timestamp is being exposed to the outside world as an RFC3339 datetime string.

Perhaps an even more convincing example is @DataType=datetime_iso8601, which contrary to what the name suggests, does NOT get stored as an ISO8601 datetime string: the timezone information is omitted in storage, because they're all converted to UTC prior to storing! (That's what #2926508: Add DateTimeNormalizer+TimestampNormalizer, deprecate TimestampItemNormalizer: @DataType-level normalizers are reusable by JSON:API is about.)

This clearly shows the need to not expose our storage implementation details to the world. And it's pointless to implement custom normalizers for every format (it's impossible to have a single @FieldType-level normalizer for both HAL and non-HAL normalizations).

Wim Leers’s picture

The last submitted patch, 9: 2957385-9.patch, failed testing. View results

Wim Leers’s picture

+++ b/core/modules/serialization/src/Normalizer/FieldItemNormalizer.php
@@ -51,7 +52,37 @@ public function denormalize($data, $class, $format = NULL, array $context = [])
   protected function constructValue($data, $context) {
-    return $data;
+    $field_item = $context['target_instance'];

This is modifying the default FieldItemNormalizer, but we still need to update HAL's ::constructValue() too. That explains the failures (the ones I expect 9-rerolled.patch to have).

Copy/pasted the exact same code, which should make it green.

Shall we keep the code duplicated or we move it into a shared trait, for example \Drupal\serialization\Normalizer\FieldableEntityNormalizerTrait?

The last submitted patch, 9: 2957385-9-tests_only.patch, failed testing. View results

The last submitted patch, 11: 2957385-9-rerolled.patch, failed testing. View results

Wim Leers’s picture

  • #9's test-only patch had 3 failures.
  • #9's tests+fix patch (proper one at #11) had 1 failure.
  • #13 has 0 failures.

This is now officially blocked on review.

Wim Leers’s picture

+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
@@ -1575,7 +1575,8 @@ protected function assertStoredEntityMatchesSentNormalization(array $sent_normal
         // Fields are stored in the database, when read they are represented
         // as strings in PHP memory. The exception: field types that are
         // stored in a serialized way. Hence we need to cast most expected

@@ -1583,6 +1584,21 @@ protected function assertStoredEntityMatchesSentNormalization(array $sent_normal
         $expected_field_normalization = ($field_type !== 'map')
           ? static::castToString($field_normalization)
           : $field_normalization;

This is now more complex than necessary. We can do this better now! That will make it less coupled to the map field type and be more robust for the future.

Wim Leers’s picture

Oops, removed one actually useful/correct/helpful comment.

gabesullice’s picture

Status: Needs review » Needs work

Whoa, I forgot all about this issue. As usual, I'm really surprised that we've gone this long without this patch! This looks good and is super close.

Shall we keep the code duplicated or we move it into a shared trait, for example \Drupal\serialization\Normalizer\FieldableEntityNormalizerTrait?

Let's not introduce a trait for this. Instead, let's add a hard-to-miss comment saying that it's duplicated from the serialization module's version of this method.


+++ b/core/modules/serialization/src/Normalizer/FieldItemNormalizer.php
@@ -51,7 +52,37 @@ public function denormalize($data, $class, $format = NULL, array $context = [])
+    if (!empty($property_definitions)) {
+      foreach ($data as $property_name => $property_value) {
+        $property_value_class = $property_definitions[$property_name]->getClass();

I think we should have an isset($property_definitions[$property_name]) check since $property_name comes from user input. Then, we can throw an exception with an informative message if the key doesn't exist.

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
10.02 KB
21.39 KB

#17 failed for MenuLinkContent + Shortcut REST tests. All other tests passed though! 💪

Simple oversight.


As usual, I'm really surprised that we've gone this long without this patch!

Yep…

Let's not introduce a trait for this. Instead, let's add a hard-to-miss comment

That trait I mentioned already exists. @gabesullice said in chat that he's then ambivalent about it. But duplicating nontrivial code seems unlikely to be approved by core committers. Plus, the docblock for \Drupal\serialization\Normalizer\FieldItemNormalizer::constructValue() already diverged from \Drupal\hal\Normalizer\FieldItemNormalizer::constructValue(), so it's already been poor at remaining consistent, so we can fix all of that in one go.

(That is the bulk of this interdiff.)

#19: Thanks for the review!

since $property_name comes from user input

Not anymore since #17 :)

Wim Leers’s picture

Gabe pointed out via chat that I misinterpreted

since $property_name comes from user input

Sorry!

I'm opting for the same approach as used by \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::assertStoredEntityMatchesSentNormalization().

(In doing so, found a nit to fix!)

gabesullice’s picture

Status: Needs review » Reviewed & tested by the community

Thanks @Wim Leers! This looks good to me now :)

alexpott’s picture

The issue summary needs updating for the TBDs...

+++ b/core/modules/serialization/src/Normalizer/FieldableEntityNormalizerTrait.php
@@ -137,4 +139,61 @@ protected function denormalizeFieldData(array $data, FieldableEntityInterface $e
+    assert($item_definition instanceof FieldItemDataDefinitionInterface);

Not sure this assert is need or correct - the interface is

  /**
   * Gets the data definition of an item of the list.
   *
   * @return \Drupal\Core\TypedData\DataDefinitionInterface
   *   A data definition describing the list items.
   */
  public function getItemDefinition();

I'm not sure we should have an assert when this is enforced by the interface. Sorry review has to end here... breakfast :)

Wim Leers’s picture

Issue summary: View changes

Issue summary updated.

Wim Leers’s picture

I'm not sure we should have an assert when this is enforced by the interface

Hm, I'm not sure what my reasoning was there either … 😅

Oh! Note how getItemDefinition() says a DataDefinitionInterface needs to be returned, not a FieldItemDataDefinitionInterface! And since we're calling getPropertyDefinitions() next, we're asserting that interface to ensure that that method actually exists.

Wim Leers’s picture

I think a change record would make sense here, but I think it'll make more sense after #2926507: Discourage @FieldType-level normalizers, encourage @DataType-level normalizers, to strengthen the API-First ecosystem lands. This issue is effectively introducing the infrastructure to make #2926507 possible at all.

A change record for #2926507 already exists: https://www.drupal.org/node/2934779. I added this issue to it. I don't think it should be published until after #2926507 also lands.

jibran’s picture

Status: Reviewed & tested by the community » Needs review

Patch looks great. Amazing work! Just some minor concerns. Setting it to NR for the first point.

  1. +++ b/core/modules/serialization/src/Normalizer/FieldableEntityNormalizerTrait.php
    @@ -137,4 +139,61 @@ protected function denormalizeFieldData(array $data, FieldableEntityInterface $e
    +      $property_value_class = $property_definitions[$item_definition->getMainPropertyName()]->getClass();
    

    In theory $item_definition->getMainPropertyName() can return NULL.

  2. +++ b/core/modules/serialization/src/Normalizer/FieldableEntityNormalizerTrait.php
    @@ -137,4 +139,61 @@ protected function denormalizeFieldData(array $data, FieldableEntityInterface $e
    +      return $this->serializer->supportsDenormalization($property_value, $property_value_class, NULL, $context)
    +        ? $this->serializer->denormalize($property_value, $property_value_class, NULL, $context)
    +        : $property_value;
    ...
    +        $data_internal[$property_name] = $this->serializer->supportsDenormalization($property_value, $property_value_class, NULL, $context)
    +          ? $this->serializer->denormalize($property_value, $property_value_class, NULL, $context)
    +          : $property_value;
    

    Can we please convert this to if-else instead?

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community
FileSize
2.33 KB
21.84 KB

Done!

jibran’s picture

Thanks, RTBC +1. I'll try to write a patch for DER to use this but that is not a blocker here.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 28: 2957385-28.patch, failed testing. View results

Wim Leers’s picture

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

Rebase powered by git, didn't apply cleanly anymore after #3020001: Error when normalizing entity reference field item that references to a new entity landed earlier today.

andypost’s picture

Just 2 nits

  1. +++ b/core/modules/hal/src/Normalizer/FieldItemNormalizer.php
    @@ -3,7 +3,9 @@
    +use Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface;
    

    unused use, counl be removed on commit

  2. +++ b/core/modules/serialization/src/Normalizer/FieldableEntityNormalizerTrait.php
    @@ -137,4 +139,71 @@ protected function denormalizeFieldData(array $data, FieldableEntityInterface $e
    +      if ($this->serializer->supportsDenormalization($property_value, $property_value_class, NULL, $context)) {
    +        return $this->serializer->denormalize($property_value, $property_value_class, NULL, $context);
    +      }
    +      else {
    +        return $property_value;
    +      }
    

    is this "else" needed? guess no but it yes, it needs comment

Wim Leers’s picture

#32.2: That else only exists because @jibran asked for it in #27. Adding a comment like // Fall back to returning without denormalizing if no denormalizer exists. doesn't seem very helpful? If you think it is, I'll add it.

andypost’s picture

Yep, makes a lot of sense!

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 31: 2957385-31.patch, failed testing. View results

Wim Leers’s picture

Status: Needs work » Reviewed & tested by the community
FileSize
21.79 KB
plach’s picture

  1. +++ b/core/modules/hal/src/Normalizer/FieldItemNormalizer.php
    @@ -11,6 +13,10 @@
    +    constructValue as protected;
    
    +++ b/core/modules/serialization/src/Normalizer/FieldItemNormalizer.php
    @@ -11,6 +11,10 @@
    +    constructValue as protected;
    

    Why protected if also the trait method is protected?

  2. +++ b/core/modules/serialization/tests/src/Kernel/FieldItemSerializationTest.php
    @@ -132,4 +148,75 @@ public function testFieldDenormalizeWithScalarValue() {
    +    // Asserts normalizing the entity DOES NOT ANYMORE yield the value we set.
    +    $core_normalization = $this->container->get('serializer')->normalize($this->entity, $format);
    +    $this->assertSame('👎', $core_normalization['field_test_boolean'][0]['value']);
    +
    +    // Asserts denormalizing the entity DOES NOT ANYMORE yield the value we set.
    +    $core_normalization = $this->container->get('serializer')->normalize($this->entity, $format);
    

    Normalization is being done twice, the latter seems redundant.

  3. +++ b/core/modules/serialization/tests/src/Kernel/FieldItemSerializationTest.php
    @@ -132,4 +148,75 @@ public function testFieldDenormalizeWithScalarValue() {
    +      'Format-agnostic @DataType-level normalizers SHOULD be able to affect the format-agnostic  normalization' => [
    

    Extra space before "normalization".

  4. +++ b/core/modules/serialization/tests/src/Kernel/FieldItemSerializationTest.php
    @@ -132,4 +148,75 @@ public function testFieldDenormalizeWithScalarValue() {
    +      'Format-agnostic @DataType-level normalizers SHOULD be able to affect the HAL+JSON normalization' => [
    +        ['test_datatype_boolean_emoji_normalizer', 'hal'],
    +        'hal_json',
    

    Why no field type test for hal+json?

plach’s picture

Also:

+++ b/core/modules/serialization/src/Normalizer/FieldableEntityNormalizerTrait.php
@@ -193,4 +195,71 @@ protected function getEntityTypeManager() {
+      if ($main_property_name === NULL) {
+        return $property_value;
+      }
+      $property_value_class = $property_definitions[$main_property_name]->getClass();
+      if ($this->serializer->supportsDenormalization($property_value, $property_value_class, NULL, $context)) {
+        return $this->serializer->denormalize($property_value, $property_value_class, NULL, $context);
+      }

Do we have test coverage for these two cases?

Wim Leers’s picture

Thanks for taking a look! 🙏


#37:

  1. Because it only needs that one method from the trait, and PHP requires to specify its visibility. The use statement is indeed specifying the same visibility as the one in the trait.
  2. ✅ Good catch, that's a c/p leftover. Removed!
  3. ✅ I used to have a reason to do this, but it was wrong, so: added! 😀 If we're doing this, we might as well also do xml to prove it works for all formats, so did that too.

#38: Fair question! Addressing #38.1 here, and #38.2 in a next comment.

#38.1: This (if ($main_property_name === NULL)) is purely theoretical (#27.1 asked for it). There is no way to make it occur in Drupal core, not even with @FieldType=map, because that requires an array. Since it's purely theoretical, I'm removing it here. There's no feasible way to test this. I agree this makes it needlessly complex. If you'd like to keep something like this, I suggest we convert it to an exception instead of an early return.

Wim Leers’s picture

#38.2: this is about allowing the main property to be implied, which $field_item->setValue() allows: it considers $node->title->setValue('foobar') and $node->title->setValue(['value' => 'foobar']) as equivalent. The first specifies only the value for the main property, relying on \Drupal\Core\TypedData\TypedDataInterface::setValue() to handle this nicely. The second specifies the property explicitly, allowing setValue() to do less work. Basically, ::setValue() implements the robustness principle aka Postel's law.
Because \Drupal\serialization\Normalizer\FieldItemNormalizer::denormalize() and \Drupal\hal\Normalizer\FieldItemNormalizer::denormalize() rely on ::setValue(), they inherit this same behavior.

Therefore, we need to do match this logic when denormalizing properties: if we receive 'foobar', then we have to assume it's the main property, so we look up which @DataType the main property has and then check if a denormalizer exists for that @DataType. If none exists, we just return the value as-is: this matches behavior in HEAD and allows @DataType denormalizers to continue to be optional.

Hopefully you were nodding along, that should've made sense :)

Now here's the thing: the \Drupal\test_fieldtype_boolean_emoji_normalizer\Normalizer\BooleanItemNormalizer::denormalize() @FieldType-level denormalizer did not yet respect the robustness principle. So before we can add explicit test coverage for #38.2, we need to make it equally forgiving (or applied to the example I've been using: right now it only accepts ['value' => foobar'], not just 'foobar').

Oh, also refactored the existing tests very slightly to make it much easier to add the test coverage requested in #38.2.

Wim Leers’s picture

And this then actually addresses #38.2: explicit test coverage for that case you pointed out!

plach’s picture

Thanks for the very detailed explanations, the interdiffs look great!

Just one last question:

Because it only needs that one method from the trait, and PHP requires to specify its visibility.

Do you mean that the notation above "imports" only the specified method in the class scope? I didn't find any mention of this in the PHP documentation, and this snippet seems to confirm that this is not what happens.

tim.plunkett’s picture

Agreed with #42, I don't see the point of this. That syntax is usually only for renaming methods or *changing* their visibility. It doesn't act as a whitelist of which methods are available.

https://3v4l.org/VvUFL


trait MyTrait {
    protected function a() {
        var_dump(__METHOD__);
    }
    protected function b() {
        var_dump(__METHOD__);
    }
}
class MyClass {
    use MyTrait {
        a as protected;
    }
    public function __construct() {
        $this->a();
        $this->b();
    }
}
new MyClass();

Output:

string(10) "MyTrait::a"
string(10) "MyTrait::b"
Wim Leers’s picture

Hah, TIL! 🐣

@plach++
@tim.plunkett++

plach’s picture

Thanks @Wim Leers!

(saving credits)

  • plach committed 1ab0932 on 8.7.x
    Issue #2957385 by Wim Leers, gabesullice, jibran, tim.plunkett:...
plach’s picture

Version: 8.7.x-dev » 8.6.x-dev
Status: Reviewed & tested by the community » Patch (to be ported)

Committed 1ab0932 and pushed to 8.7.x. Thanks!

Patch doesn't apply to 8.6.x cleanly, feel free to mark this fixed if it should not be backported.

Wim Leers’s picture

Version: 8.6.x-dev » 8.7.x-dev
Status: Patch (to be ported) » Fixed

YAY! 🥳

It doesn't need backporting indeed :)

Status: Fixed » Closed (fixed)

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