.../hal/src/Normalizer/FieldItemNormalizer.php | 20 ++--- .../EntityResource/EntityResourceTestBase.php | 43 ++++++++--- .../src/Normalizer/FieldItemNormalizer.php | 23 +----- .../Normalizer/FieldableEntityNormalizerTrait.php | 54 ++++++++++++++ .../src/Normalizer/BooleanNormalizer.php | 36 +++++++++ ...test_datatype_boolean_emoji_normalizer.info.yml | 6 ++ ..._datatype_boolean_emoji_normalizer.services.yml | 6 ++ .../src/Normalizer/BooleanItemNormalizer.php | 39 ++++++++++ ...est_fieldtype_boolean_emoji_normalizer.info.yml | 6 ++ ...fieldtype_boolean_emoji_normalizer.services.yml | 6 ++ .../src/Kernel/FieldItemSerializationTest.php | 87 ++++++++++++++++++++++ .../EntityReferenceFieldItemNormalizerTest.php | 3 + 12 files changed, 286 insertions(+), 43 deletions(-) diff --git a/core/modules/hal/src/Normalizer/FieldItemNormalizer.php b/core/modules/hal/src/Normalizer/FieldItemNormalizer.php index f5b2ec0..090e5d4 100644 --- a/core/modules/hal/src/Normalizer/FieldItemNormalizer.php +++ b/core/modules/hal/src/Normalizer/FieldItemNormalizer.php @@ -4,6 +4,7 @@ use Drupal\Core\Field\FieldItemInterface; use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper; +use Drupal\serialization\Normalizer\FieldableEntityNormalizerTrait; use Symfony\Component\Serializer\Exception\InvalidArgumentException; /** @@ -11,6 +12,10 @@ */ class FieldItemNormalizer extends NormalizerBase { + use FieldableEntityNormalizerTrait { + constructValue as protected; + } + /** * The interface or class that this Normalizer supports. * @@ -60,21 +65,6 @@ public function denormalize($data, $class, $format = NULL, array $context = []) } /** - * Build the field item value using the incoming data. - * - * @param $data - * The incoming data for this field item. - * @param $context - * The context passed into the Normalizer. - * - * @return mixed - * The value to use in Entity::setValue(). - */ - protected function constructValue($data, $context) { - return $data; - } - - /** * Normalizes field values for an item. * * @param \Drupal\Core\Field\FieldItemInterface $field_item diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php index d2d1165..4f78350 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php @@ -1575,18 +1575,43 @@ protected function assertStoredEntityMatchesSentNormalization(array $sent_normal // Some top-level keys in the normalization may not be fields on the // entity (for example '_links' and '_embedded' in the HAL normalization). if ($modified_entity->hasField($field_name)) { - $field_type = $modified_entity->get($field_name)->getFieldDefinition()->getType(); - // 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 - // field normalizations to strings. - $expected_field_normalization = ($field_type !== 'map') - ? static::castToString($field_normalization) - : $field_normalization; + $field_definition = $modified_entity->get($field_name)->getFieldDefinition(); + $property_definitions = $field_definition->getItemDefinition()->getPropertyDefinitions(); + $expected_stored_data = []; + // Some fields don't have any property definitions, so there's nothing + // to denormalize. + if (empty($property_definitions)) { + $expected_stored_data = $field_normalization; + } + else { + // Denormalize every sent field item property to make it possible to + // compare against the stored value. + $denormalization_context = ['field_definition' => $field_definition]; + foreach ($field_normalization as $delta => $expected_field_item_normalization) { + foreach ($property_definitions as $property_name => $property_definition) { + // Not every property is required to be sent + if (!array_key_exists($property_name, $field_normalization[$delta])) { + continue; + } + // Computed properties are not stored. + if ($property_definition->isComputed()) { + continue; + } + $property_value = $field_normalization[$delta][$property_name]; + $property_value_class = $property_definitions[$property_name]->getClass(); + $expected_stored_data[$delta][$property_name] = $this->serializer->supportsDenormalization($property_value, $property_value_class, NULL, $denormalization_context) + ? $this->serializer->denormalize($property_value, $property_value_class, NULL, $denormalization_context) + : $property_value; + } + } + // Fields are stored in the database, when read they are represented + // as strings in PHP memory. + $expected_stored_data = static::castToString($expected_stored_data); + } // Subset, not same, because we can e.g. send just the target_id for the // bundle in a PATCH or POST request; the response will include more // properties. - $this->assertArraySubset($expected_field_normalization, $modified_entity->get($field_name)->getValue(), TRUE); + $this->assertArraySubset($expected_stored_data, $modified_entity->get($field_name)->getValue(), TRUE); } } } diff --git a/core/modules/serialization/src/Normalizer/FieldItemNormalizer.php b/core/modules/serialization/src/Normalizer/FieldItemNormalizer.php index decca43..014cd30 100644 --- a/core/modules/serialization/src/Normalizer/FieldItemNormalizer.php +++ b/core/modules/serialization/src/Normalizer/FieldItemNormalizer.php @@ -11,6 +11,10 @@ */ class FieldItemNormalizer extends ComplexDataNormalizer implements DenormalizerInterface { + use FieldableEntityNormalizerTrait { + constructValue as protected; + } + /** * {@inheritdoc} */ @@ -35,23 +39,4 @@ public function denormalize($data, $class, $format = NULL, array $context = []) return $field_item; } - /** - * Build the field item value using the incoming data. - * - * Most normalizers that extend this class can simply use this method to - * construct the denormalized value without having to override denormalize() - * and reimplementing its validation logic or its call to set the field value. - * - * @param mixed $data - * The incoming data for this field item. - * @param array $context - * The context passed into the Normalizer. - * - * @return mixed - * The value to use in Entity::setValue(). - */ - protected function constructValue($data, $context) { - return $data; - } - } diff --git a/core/modules/serialization/src/Normalizer/FieldableEntityNormalizerTrait.php b/core/modules/serialization/src/Normalizer/FieldableEntityNormalizerTrait.php index c8211a8..1973273 100644 --- a/core/modules/serialization/src/Normalizer/FieldableEntityNormalizerTrait.php +++ b/core/modules/serialization/src/Normalizer/FieldableEntityNormalizerTrait.php @@ -4,6 +4,8 @@ use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\Core\Field\FieldItemInterface; +use Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface; use Symfony\Component\Serializer\Exception\UnexpectedValueException; /** @@ -137,4 +139,56 @@ protected function denormalizeFieldData(array $data, FieldableEntityInterface $e } } + /** + * Build the field item value using the incoming data. + * + * Most normalizers that extend this class can simply use this method to + * construct the denormalized value without having to override denormalize() + * and reimplementing its validation logic or its call to set the field value. + * + * It's recommended to not override this and instead provide a (de)normalizer + * at the DataType level. + * + * @param mixed $data + * The incoming data for this field item. + * @param array $context + * The context passed into the Normalizer. + * + * @return mixed + * The value to use in Entity::setValue(). + */ + protected function constructValue($data, $context) { + $field_item = $context['target_instance']; + + // Get the property definitions. + assert($field_item instanceof FieldItemInterface); + $field_definition = $field_item->getFieldDefinition(); + $item_definition = $field_definition->getItemDefinition(); + assert($item_definition instanceof FieldItemDataDefinitionInterface); + $property_definitions = $item_definition->getPropertyDefinitions(); + + if (!is_array($data)) { + $property_value = $data; + $property_value_class = $property_definitions[$item_definition->getMainPropertyName()]->getClass(); + 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 = []; + if (!empty($property_definitions)) { + foreach ($data as $property_name => $property_value) { + $property_value_class = $property_definitions[$property_name]->getClass(); + $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; + } + } + else { + $data_internal = $data; + } + + return $data_internal; + } + } diff --git a/core/modules/serialization/tests/modules/test_datatype_boolean_emoji_normalizer/src/Normalizer/BooleanNormalizer.php b/core/modules/serialization/tests/modules/test_datatype_boolean_emoji_normalizer/src/Normalizer/BooleanNormalizer.php new file mode 100644 index 0000000..b1b1bd8 --- /dev/null +++ b/core/modules/serialization/tests/modules/test_datatype_boolean_emoji_normalizer/src/Normalizer/BooleanNormalizer.php @@ -0,0 +1,36 @@ +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 === '👍'; + } + +} diff --git a/core/modules/serialization/tests/modules/test_datatype_boolean_emoji_normalizer/test_datatype_boolean_emoji_normalizer.info.yml b/core/modules/serialization/tests/modules/test_datatype_boolean_emoji_normalizer/test_datatype_boolean_emoji_normalizer.info.yml new file mode 100644 index 0000000..0dabae3 --- /dev/null +++ b/core/modules/serialization/tests/modules/test_datatype_boolean_emoji_normalizer/test_datatype_boolean_emoji_normalizer.info.yml @@ -0,0 +1,6 @@ +name: 'Test @DataType normalizer' +type: module +description: 'Provides test support for @DataType-level normalization.' +package: Testing +version: VERSION +core: 8.x diff --git a/core/modules/serialization/tests/modules/test_datatype_boolean_emoji_normalizer/test_datatype_boolean_emoji_normalizer.services.yml b/core/modules/serialization/tests/modules/test_datatype_boolean_emoji_normalizer/test_datatype_boolean_emoji_normalizer.services.yml new file mode 100644 index 0000000..5cde957 --- /dev/null +++ b/core/modules/serialization/tests/modules/test_datatype_boolean_emoji_normalizer/test_datatype_boolean_emoji_normalizer.services.yml @@ -0,0 +1,6 @@ +services: + serializer.normalizer.boolean.datatype.emoji: + class: Drupal\test_datatype_boolean_emoji_normalizer\Normalizer\BooleanNormalizer + tags: + # The priority must be higher than serializer.normalizer.primitive_data. + - { name: normalizer , priority: 1000 } diff --git a/core/modules/serialization/tests/modules/test_fieldtype_boolean_emoji_normalizer/src/Normalizer/BooleanItemNormalizer.php b/core/modules/serialization/tests/modules/test_fieldtype_boolean_emoji_normalizer/src/Normalizer/BooleanItemNormalizer.php new file mode 100644 index 0000000..57139cc --- /dev/null +++ b/core/modules/serialization/tests/modules/test_fieldtype_boolean_emoji_normalizer/src/Normalizer/BooleanItemNormalizer.php @@ -0,0 +1,39 @@ + 0, ], ])->save(); + FieldStorageConfig::create([ + 'entity_type' => 'entity_test_mulrev', + 'field_name' => 'field_test_boolean', + 'type' => 'boolean', + 'cardinality' => 1, + 'translatable' => FALSE, + ])->save(); + FieldConfig::create([ + 'entity_type' => 'entity_test_mulrev', + 'field_name' => 'field_test_boolean', + 'bundle' => 'entity_test_mulrev', + 'label' => 'Test boolean', + ])->save(); // Create a test entity to serialize. $this->values = [ @@ -85,6 +98,9 @@ protected function setUp() { 'value' => $this->randomMachineName(), 'format' => 'full_html', ], + 'field_test_boolean' => [ + 'value' => FALSE, + ], ]; $this->entity = EntityTestMulRev::create($this->values); $this->entity->save(); @@ -132,4 +148,75 @@ public function testFieldDenormalizeWithScalarValue() { $this->serializer->denormalize($normalized, $this->entityClass, 'json'); } + /** + * Tests a format-agnostic normalizer. + * + * @param string[] $test_modules + * The test modules to install. + * @param string $format + * The format to test. (NULL results in the format-agnostic normalization.) + * + * @dataProvider providerTestCustomBooleanNormalization + */ + public function testCustomBooleanNormalization(array $test_modules, $format) { + // Asserts the entity contains the value we set. + $this->assertSame(FALSE, $this->entity->field_test_boolean->value); + + // Asserts normalizing the entity using core's 'serializer' service DOES + // yield the value we set. + $core_normalization = $this->container->get('serializer')->normalize($this->entity, $format); + $this->assertSame(FALSE, $core_normalization['field_test_boolean'][0]['value']); + + // Asserts denormalizing the entity DOES yield the value we set. + $core_normalization['field_test_boolean'][0]['value'] = TRUE; + $denormalized_entity = $this->container->get('serializer')->denormalize($core_normalization, EntityTestMulRev::class, $format, []); + $this->assertInstanceOf(EntityTestMulRev::class, $denormalized_entity); + $this->assertSame(TRUE, $denormalized_entity->field_test_boolean->value); + + // Install test module that contains a high-priority alternative normalizer. + $this->enableModules($test_modules); + + // 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); + $core_normalization['field_test_boolean'][0]['value'] = '👍'; + $denormalized_entity = $this->container->get('serializer')->denormalize($core_normalization, EntityTestMulRev::class, $format, []); + $this->assertInstanceOf(EntityTestMulRev::class, $denormalized_entity); + $this->assertSame(TRUE, $denormalized_entity->field_test_boolean->value); + } + + /** + * Data provider. + * + * @return array + * Test cases. + */ + public function providerTestCustomBooleanNormalization() { + return [ + 'Format-agnostic @FieldType-level normalizers SHOULD be able to affect the format-agnostic normalization' => [ + ['test_fieldtype_boolean_emoji_normalizer'], + NULL, + ], + 'Format-agnostic @DataType-level normalizers SHOULD be able to affect the format-agnostic normalization' => [ + ['test_datatype_boolean_emoji_normalizer'], + NULL, + ], + 'Format-agnostic @FieldType-level normalizers SHOULD be able to affect the JSON normalization' => [ + ['test_fieldtype_boolean_emoji_normalizer'], + 'json', + ], + 'Format-agnostic @DataType-level normalizers SHOULD be able to affect the JSON normalization' => [ + ['test_datatype_boolean_emoji_normalizer'], + 'json', + ], + 'Format-agnostic @DataType-level normalizers SHOULD be able to affect the HAL+JSON normalization' => [ + ['test_datatype_boolean_emoji_normalizer', 'hal'], + 'hal_json', + ], + ]; + } + } diff --git a/core/modules/serialization/tests/src/Unit/Normalizer/EntityReferenceFieldItemNormalizerTest.php b/core/modules/serialization/tests/src/Unit/Normalizer/EntityReferenceFieldItemNormalizerTest.php index a2cf635..e1ff1e8 100644 --- a/core/modules/serialization/tests/src/Unit/Normalizer/EntityReferenceFieldItemNormalizerTest.php +++ b/core/modules/serialization/tests/src/Unit/Normalizer/EntityReferenceFieldItemNormalizerTest.php @@ -4,6 +4,7 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\Field\TypedData\FieldItemDataDefinition; use Drupal\Core\GeneratedUrl; use Drupal\Core\TypedData\Type\IntegerInterface; use Drupal\Core\TypedData\TypedDataInterface; @@ -83,6 +84,8 @@ protected function setUp() { ->willReturn(new \ArrayIterator(['target_id' => []])); $this->fieldDefinition = $this->prophesize(FieldDefinitionInterface::class); + $this->fieldDefinition->getItemDefinition() + ->willReturn($this->prophesize(FieldItemDataDefinition::class)->reveal()); }