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
Automatically call::denormalize()
!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.
Comment | File | Size | Author |
---|---|---|---|
#44 | 2957385-44.patch | 22.64 KB | Wim Leers |
#44 | interdiff.txt | 1.26 KB | Wim Leers |
#41 | 2957385-41.patch | 22.73 KB | Wim Leers |
#41 | interdiff.txt | 2.03 KB | Wim Leers |
#40 | 2957385-40.patch | 22.31 KB | Wim Leers |
Comments
Comment #2
Wim LeersComment #3
Wim LeersThe 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.
Comment #5
Wim Leers#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 🤘
Comment #7
Wim LeersWoah, very close, only
EntityTestMapField(Json|HalJson)AnonTest
failed andDrupal\Tests\serialization\Unit\Normalizer\EntityReferenceFieldItemNormalizerTest
!Comment #8
Wim LeersEasy. One oversight that only manifests itself for
@FieldType=map
, and one mock.This should be green!
Comment #9
Wim LeersWe probably want to add test coverage here.
Tests-only patch is also the interdiff.
Comment #10
Wim LeersThis 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).Comment #11
Wim Leershttps://www.drupal.org/files/issues/2018-12-28/2957385-9.patch didn't include #7. D'oh. Test cancellation requested.
Comment #13
Wim LeersThis is modifying the default
FieldItemNormalizer
, but we still need to update HAL's::constructValue()
too. That explains the failures (the ones I expect9-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
?Comment #16
Wim Leers✅
This is now officially blocked on review.
Comment #17
Wim LeersThis 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.Comment #18
Wim LeersOops, removed one actually useful/correct/helpful comment.
Comment #19
gabesulliceWhoa, 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.
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.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.Comment #20
Wim Leers#17 failed for
MenuLinkContent
+Shortcut
REST tests. All other tests passed though! 💪Simple oversight.
Yep…
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!
Not anymore since #17 :)
Comment #21
Wim LeersGabe pointed out via chat that I misinterpreted
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!)
Comment #22
gabesulliceThanks @Wim Leers! This looks good to me now :)
Comment #23
alexpottThe issue summary needs updating for the TBDs...
Not sure this assert is need or correct - the interface is
I'm not sure we should have an assert when this is enforced by the interface. Sorry review has to end here... breakfast :)
Comment #24
Wim LeersIssue summary updated.
Comment #25
Wim LeersHm, I'm not sure what my reasoning was there either … 😅Oh! Note how
getItemDefinition()
says aDataDefinitionInterface
needs to be returned, not aFieldItemDataDefinitionInterface
! And since we're callinggetPropertyDefinitions()
next, we're asserting that interface to ensure that that method actually exists.Comment #26
Wim LeersI 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.
Comment #27
jibranPatch looks great. Amazing work! Just some minor concerns. Setting it to NR for the first point.
In theory
$item_definition->getMainPropertyName()
can return NULL.Can we please convert this to if-else instead?
Comment #28
Wim LeersDone!
Comment #29
jibranThanks, RTBC +1. I'll try to write a patch for DER to use this but that is not a blocker here.
Comment #31
Wim LeersRebase 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.
Comment #32
andypostJust 2 nits
unused use, counl be removed on commit
is this "else" needed? guess no but it yes, it needs comment
Comment #33
Wim Leers#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.Comment #34
andypostYep, makes a lot of sense!
Comment #36
Wim Leers#2977107: Use more specific entity.manager services in module .services.yml files conflicted with this in a trivial way. Rebased.
Comment #37
plachWhy
protected
if also the trait method isprotected
?Normalization is being done twice, the latter seems redundant.
Extra space before "normalization".
Why no field type test for hal+json?
Comment #38
plachAlso:
Do we have test coverage for these two cases?
Comment #39
Wim LeersThanks for taking a look! 🙏
#37:
use
statement is indeed specifying the same visibility as the one in the trait.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.Comment #40
Wim Leers#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, allowingsetValue()
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.
Comment #41
Wim LeersAnd this then actually addresses #38.2: explicit test coverage for that case you pointed out!
Comment #42
plachThanks for the very detailed explanations, the interdiffs look great!
Just one last question:
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.
Comment #43
tim.plunkettAgreed 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
Output:
Comment #44
Wim LeersHah, TIL! 🐣
@plach++
@tim.plunkett++
Comment #45
plachThanks @Wim Leers!
(saving credits)
Comment #47
plachCommitted 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.
Comment #48
Wim LeersYAY! 🥳
It doesn't need backporting indeed :)