Issue #2895532 by caseylau, Wim Leers, tedbow, dawehner, DamienMcKenna, gabesullice, Berdir, mistermoper, skyredwang, larowlan, bojanz: @DataType=map cannot be normalized, affects @FieldType=link, @FieldType=map

8.7.x
Nathaniel Catchpole 2018-08-16 11:35:24 +09:00
parent 3f44f5bbcb
commit 725e36091e
13 changed files with 610 additions and 31 deletions

View File

@ -168,9 +168,8 @@ class TypedDataManager extends DefaultPluginManager implements TypedDataManagerI
// a shorter string than the serialized form, so array access is faster. // a shorter string than the serialized form, so array access is faster.
$parts[] = json_encode($settings); $parts[] = json_encode($settings);
} }
// Property path for the requested data object. When creating a list item, // Property path for the requested data object.
// use 0 in the key as all items look the same. $parts[] = $object->getPropertyPath() . '.' . $property_name;
$parts[] = $object->getPropertyPath() . '.' . (is_numeric($property_name) ? 0 : $property_name);
$key = implode(':', $parts); $key = implode(':', $parts);
// Create the prototype if needed. // Create the prototype if needed.

View File

@ -92,7 +92,10 @@ class FieldItemNormalizer extends NormalizerBase {
// We normalize each individual property, so each can do their own casting, // We normalize each individual property, so each can do their own casting,
// if needed. // if needed.
/** @var \Drupal\Core\TypedData\TypedDataInterface $property */ /** @var \Drupal\Core\TypedData\TypedDataInterface $property */
foreach (TypedDataInternalPropertiesHelper::getNonInternalProperties($field_item) as $property_name => $property) { $field_properties = !empty($field_item->getProperties(TRUE))
? TypedDataInternalPropertiesHelper::getNonInternalProperties($field_item)
: $field_item->getValue();
foreach ($field_properties as $property_name => $property) {
$normalized[$property_name] = $this->serializer->normalize($property, $format, $context); $normalized[$property_name] = $this->serializer->normalize($property, $format, $context);
} }

View File

@ -30,7 +30,7 @@ class LinkNotExistingInternalConstraintValidator extends ConstraintValidator {
if ($url->isRouted()) { if ($url->isRouted()) {
$allowed = TRUE; $allowed = TRUE;
try { try {
$url->toString(); $url->toString(TRUE);
} }
// The following exceptions are all possible during URL generation, and // The following exceptions are all possible during URL generation, and
// should be considered as disallowed URLs. // should be considered as disallowed URLs.

View File

@ -0,0 +1,83 @@
<?php
namespace Drupal\Tests\link\Kernel;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\field\Entity\FieldConfig;
use Drupal\link\LinkItemInterface;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Tests\field\Kernel\FieldKernelTestBase;
/**
* Tests link field serialization.
*
* @group link
*/
class LinkItemSerializationTest extends FieldKernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['link', 'serialization'];
/**
* The serializer service.
*
* @var \Symfony\Component\Serializer\SerializerInterface
*/
protected $serializer;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->installEntitySchema('user');
$this->serializer = \Drupal::service('serializer');
// Create a generic, external, and internal link fields for validation.
FieldStorageConfig::create([
'entity_type' => 'entity_test',
'field_name' => 'field_test',
'type' => 'link',
])->save();
FieldConfig::create([
'entity_type' => 'entity_test',
'field_name' => 'field_test',
'bundle' => 'entity_test',
'settings' => ['link_type' => LinkItemInterface::LINK_GENERIC],
])->save();
}
/**
* Tests the serialization.
*/
public function testLinkSerialization() {
// Create entity.
$entity = EntityTest::create();
$url = 'https://www.drupal.org?test_param=test_value';
$parsed_url = UrlHelper::parse($url);
$title = $this->randomMachineName();
$class = $this->randomMachineName();
$entity->field_test->uri = $parsed_url['path'];
$entity->field_test->title = $title;
$entity->field_test->first()
->get('options')
->set('query', $parsed_url['query']);
$entity->field_test->first()
->get('options')
->set('attributes', ['class' => $class]);
$entity->save();
$serialized = $this->serializer->serialize($entity, 'json');
$deserialized = $this->serializer->deserialize($serialized, EntityTest::class, 'json');
$options_expected = [
'query' => $parsed_url['query'],
'attributes' => ['class' => $class],
];
$this->assertSame($options_expected, $deserialized->field_test->options);
}
}

View File

@ -59,7 +59,15 @@ abstract class MenuLinkContentResourceTestBase extends EntityResourceTestBase {
'id' => 'llama', 'id' => 'llama',
'title' => 'Llama Gabilondo', 'title' => 'Llama Gabilondo',
'description' => 'Llama Gabilondo', 'description' => 'Llama Gabilondo',
'link' => 'https://nl.wikipedia.org/wiki/Llama', 'link' => [
'uri' => 'https://nl.wikipedia.org/wiki/Llama',
'options' => [
'fragment' => 'a-fragment',
'attributes' => [
'class' => ['example-class'],
],
],
],
'weight' => 0, 'weight' => 0,
'menu_name' => 'main', 'menu_name' => 'main',
]); ]);
@ -81,6 +89,12 @@ abstract class MenuLinkContentResourceTestBase extends EntityResourceTestBase {
'link' => [ 'link' => [
[ [
'uri' => 'http://www.urbandictionary.com/define.php?term=drama%20llama', 'uri' => 'http://www.urbandictionary.com/define.php?term=drama%20llama',
'options' => [
'fragment' => 'a-fragment',
'attributes' => [
'class' => ['example-class'],
],
],
], ],
], ],
'bundle' => [ 'bundle' => [
@ -115,7 +129,12 @@ abstract class MenuLinkContentResourceTestBase extends EntityResourceTestBase {
[ [
'uri' => 'https://nl.wikipedia.org/wiki/Llama', 'uri' => 'https://nl.wikipedia.org/wiki/Llama',
'title' => NULL, 'title' => NULL,
'options' => [], 'options' => [
'fragment' => 'a-fragment',
'attributes' => [
'class' => ['example-class'],
],
],
], ],
], ],
'weight' => [ 'weight' => [

View File

@ -617,7 +617,10 @@ abstract class EntityResourceTestBase extends ResourceTestBase {
// Only run this for fieldable entities. It doesn't make sense for config // Only run this for fieldable entities. It doesn't make sense for config
// entities as config values are already casted. They also run through the // entities as config values are already casted. They also run through the
// ConfigEntityNormalizer, which doesn't deal with fields individually. // ConfigEntityNormalizer, which doesn't deal with fields individually.
if ($this->entity instanceof FieldableEntityInterface) { // Also exclude entity_test_map_field — that has a "map" base field, which
// only became normalizable since Drupal 8.6, so its normalization
// containing non-stringified numbers or booleans does not break BC.
if ($this->entity instanceof FieldableEntityInterface && static::$entityTypeId !== 'entity_test_map_field') {
// Test primitive data casting BC (strings). // Test primitive data casting BC (strings).
$this->config('serialization.settings')->set('bc_primitives_as_strings', TRUE)->save(TRUE); $this->config('serialization.settings')->set('bc_primitives_as_strings', TRUE)->save(TRUE);
// Rebuild the container so new config is reflected in the addition of the // Rebuild the container so new config is reflected in the addition of the
@ -921,17 +924,7 @@ abstract class EntityResourceTestBase extends ResourceTestBase {
$created_entity = $this->entityStorage->loadUnchanged(static::$firstCreatedEntityId); $created_entity = $this->entityStorage->loadUnchanged(static::$firstCreatedEntityId);
$created_entity_normalization = $this->serializer->normalize($created_entity, static::$format, ['account' => $this->account]); $created_entity_normalization = $this->serializer->normalize($created_entity, static::$format, ['account' => $this->account]);
$this->assertSame($created_entity_normalization, $this->serializer->decode((string) $response->getBody(), static::$format)); $this->assertSame($created_entity_normalization, $this->serializer->decode((string) $response->getBody(), static::$format));
// Assert that the entity was indeed created using the POSTed values. $this->assertStoredEntityMatchesSentNormalization($this->getNormalizedPostEntity(), $created_entity);
foreach ($this->getNormalizedPostEntity() as $field_name => $field_normalization) {
// Some top-level keys in the normalization may not be fields on the
// entity (for example '_links' and '_embedded' in the HAL normalization).
if ($created_entity->hasField($field_name)) {
// Subset, not same, because we can e.g. send just the target_id for the
// bundle in a POST request; the response will include more properties.
$this->assertArraySubset(static::castToString($field_normalization), $created_entity->get($field_name)
->getValue(), TRUE);
}
}
} }
$this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE); $this->config('rest.settings')->set('bc_entity_resource_permissions', TRUE)->save(TRUE);
@ -1182,16 +1175,7 @@ abstract class EntityResourceTestBase extends ResourceTestBase {
$updated_entity = $this->entityStorage->loadUnchanged($this->entity->id()); $updated_entity = $this->entityStorage->loadUnchanged($this->entity->id());
$updated_entity_normalization = $this->serializer->normalize($updated_entity, static::$format, ['account' => $this->account]); $updated_entity_normalization = $this->serializer->normalize($updated_entity, static::$format, ['account' => $this->account]);
$this->assertSame($updated_entity_normalization, $this->serializer->decode((string) $response->getBody(), static::$format)); $this->assertSame($updated_entity_normalization, $this->serializer->decode((string) $response->getBody(), static::$format));
// Assert that the entity was indeed created using the PATCHed values. $this->assertStoredEntityMatchesSentNormalization($this->getNormalizedPatchEntity(), $updated_entity);
foreach ($this->getNormalizedPatchEntity() as $field_name => $field_normalization) {
// Some top-level keys in the normalization may not be fields on the
// entity (for example '_links' and '_embedded' in the HAL normalization).
if ($updated_entity->hasField($field_name)) {
// Subset, not same, because we can e.g. send just the target_id for the
// bundle in a PATCH request; the response will include more properties.
$this->assertArraySubset(static::castToString($field_normalization), $updated_entity->get($field_name)->getValue(), TRUE);
}
}
// Ensure that fields do not get deleted if they're not present in the PATCH // Ensure that fields do not get deleted if they're not present in the PATCH
// request. Test this using the configurable field that we added, but which // request. Test this using the configurable field that we added, but which
// is not sent in the PATCH request. // is not sent in the PATCH request.
@ -1516,4 +1500,33 @@ abstract class EntityResourceTestBase extends ResourceTestBase {
} }
} }
/**
* Asserts that the stored entity matches the sent normalization.
*
* @param array $sent_normalization
* An entity normalization.
* @param \Drupal\Core\Entity\FieldableEntityInterface $modified_entity
* The entity object of the modified (PATCHed or POSTed) entity.
*/
protected function assertStoredEntityMatchesSentNormalization(array $sent_normalization, FieldableEntityInterface $modified_entity) {
foreach ($sent_normalization as $field_name => $field_normalization) {
// 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;
// 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);
}
}
}
} }

View File

@ -34,7 +34,10 @@ class ComplexDataNormalizer extends NormalizerBase {
// Other normalizers that extend this class may only provide $object that // Other normalizers that extend this class may only provide $object that
// implements \Traversable. // implements \Traversable.
if ($object instanceof ComplexDataInterface) { if ($object instanceof ComplexDataInterface) {
$object = TypedDataInternalPropertiesHelper::getNonInternalProperties($object); // If there are no properties to normalize, just normalize the value.
$object = !empty($object->getProperties(TRUE))
? TypedDataInternalPropertiesHelper::getNonInternalProperties($object)
: $object->getValue();
} }
/** @var \Drupal\Core\TypedData\TypedDataInterface $property */ /** @var \Drupal\Core\TypedData\TypedDataInterface $property */
foreach ($object as $name => $property) { foreach ($object as $name => $property) {

View File

@ -0,0 +1,136 @@
<?php
namespace Drupal\Tests\serialization\Kernel;
use Drupal\Core\TypedData\DataDefinition;
use Drupal\Core\TypedData\MapDataDefinition;
use Drupal\KernelTests\KernelTestBase;
/**
* @group typedData
*/
class MapDataNormalizerTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['system', 'serialization'];
/**
* The serializer service.
*
* @var \Symfony\Component\Serializer\Serializer
*/
protected $serializer;
/**
* The typed data manager.
*
* @var \Drupal\Core\TypedData\TypedDataManagerInterface
*/
protected $typedDataManager;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
$this->serializer = \Drupal::service('serializer');
$this->typedDataManager = \Drupal::typedDataManager();
}
/**
* Tests whether map data can be normalized.
*/
public function testMapNormalize() {
$typed_data = $this->buildExampleTypedData();
$data = $this->serializer->normalize($typed_data, 'json');
$expect_value = [
'key1' => 'value1',
'key2' => 'value2',
'key3' => 3,
'key4' => [
0 => TRUE,
1 => 'value6',
'key7' => 'value7',
],
];
$this->assertSame($expect_value, $data);
}
/**
* Test whether map data with properties can be normalized.
*/
public function testMapWithPropertiesNormalize() {
$typed_data = $this->buildExampleTypedDataWithProperties();
$data = $this->serializer->normalize($typed_data, 'json');
$expect_value = [
'key1' => 'value1',
'key2' => 'value2',
'key3' => 3,
'key4' => [
0 => TRUE,
1 => 'value6',
'key7' => 'value7',
],
];
$this->assertSame($expect_value, $data);
}
/**
* Builds some example typed data object with no properties.
*/
protected function buildExampleTypedData() {
$tree = [
'key1' => 'value1',
'key2' => 'value2',
'key3' => 3,
'key4' => [
0 => TRUE,
1 => 'value6',
'key7' => 'value7',
],
];
$map_data_definition = MapDataDefinition::create();
$typed_data = $this->typedDataManager->create(
$map_data_definition,
$tree,
'test name'
);
return $typed_data;
}
/**
* Builds some example typed data object with properties.
*/
protected function buildExampleTypedDataWithProperties() {
$tree = [
'key1' => 'value1',
'key2' => 'value2',
'key3' => 3,
'key4' => [
0 => TRUE,
1 => 'value6',
'key7' => 'value7',
],
];
$map_data_definition = MapDataDefinition::create()
->setPropertyDefinition('key1', DataDefinition::create('string'))
->setPropertyDefinition('key2', DataDefinition::create('string'))
->setPropertyDefinition('key3', DataDefinition::create('integer'))
->setPropertyDefinition('key4', MapDataDefinition::create()
->setPropertyDefinition(0, DataDefinition::create('boolean'))
->setPropertyDefinition(1, DataDefinition::create('string'))
->setPropertyDefinition('key7', DataDefinition::create('string'))
);
$typed_data = $this->typedDataManager->create(
$map_data_definition,
$tree,
'test name'
);
return $typed_data;
}
}

View File

@ -58,6 +58,9 @@ abstract class ShortcutResourceTestBase extends EntityResourceTestBase {
'weight' => -20, 'weight' => -20,
'link' => [ 'link' => [
'uri' => 'internal:/admin/content/comment', 'uri' => 'internal:/admin/content/comment',
'options' => [
'fragment' => 'new',
],
], ],
]); ]);
$shortcut->save(); $shortcut->save();
@ -96,7 +99,9 @@ abstract class ShortcutResourceTestBase extends EntityResourceTestBase {
[ [
'uri' => 'internal:/admin/content/comment', 'uri' => 'internal:/admin/content/comment',
'title' => NULL, 'title' => NULL,
'options' => [], 'options' => [
'fragment' => 'new',
],
], ],
], ],
'weight' => [ 'weight' => [

View File

@ -0,0 +1,39 @@
<?php
namespace Drupal\entity_test\Entity;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
/**
* An entity used for testing map base field values.
*
* @ContentEntityType(
* id = "entity_test_map_field",
* label = @Translation("Entity Test map field"),
* base_table = "entity_test_map_field",
* entity_keys = {
* "uuid" = "uuid",
* "id" = "id",
* "label" = "name",
* "langcode" = "langcode",
* },
* admin_permission = "administer entity_test content",
* )
*/
class EntityTestMapField extends EntityTest {
/**
* {@inheritdoc}
*/
public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
$fields = parent::baseFieldDefinitions($entity_type);
$fields['data'] = BaseFieldDefinition::create('map')
->setLabel(t('Data'))
->setDescription(t('A serialized array of additional data.'));
return $fields;
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace Drupal\Tests\entity_test\Functional\Hal;
use Drupal\Tests\entity_test\Functional\Rest\EntityTestMapFieldResourceTestBase;
use Drupal\Tests\hal\Functional\EntityResource\HalEntityNormalizationTrait;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
use Drupal\user\Entity\User;
/**
* @group hal
*/
class EntityTestMapFieldHalJsonAnonTest extends EntityTestMapFieldResourceTestBase {
use HalEntityNormalizationTrait;
use AnonResourceTestTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['hal'];
/**
* {@inheritdoc}
*/
protected static $format = 'hal_json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/hal+json';
/**
* {@inheritdoc}
*/
protected function getExpectedNormalizedEntity() {
$default_normalization = parent::getExpectedNormalizedEntity();
$normalization = $this->applyHalFieldNormalization($default_normalization);
$author = User::load(0);
return $normalization + [
'_links' => [
'self' => [
'href' => '',
],
'type' => [
'href' => $this->baseUrl . '/rest/type/entity_test_map_field/entity_test_map_field',
],
$this->baseUrl . '/rest/relation/entity_test_map_field/entity_test_map_field/user_id' => [
[
'href' => $this->baseUrl . '/user/0?_format=hal_json',
'lang' => 'en',
],
],
],
'_embedded' => [
$this->baseUrl . '/rest/relation/entity_test_map_field/entity_test_map_field/user_id' => [
[
'_links' => [
'self' => [
'href' => $this->baseUrl . '/user/0?_format=hal_json',
],
'type' => [
'href' => $this->baseUrl . '/rest/type/user/user',
],
],
'uuid' => [
[
'value' => $author->uuid(),
],
],
'lang' => 'en',
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getNormalizedPostEntity() {
return parent::getNormalizedPostEntity() + [
'_links' => [
'type' => [
'href' => $this->baseUrl . '/rest/type/entity_test_map_field/entity_test_map_field',
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheContexts() {
return [
'url.site',
'user.permissions',
];
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace Drupal\Tests\entity_test\Functional\Rest;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
/**
* @group rest
*/
class EntityTestMapFieldJsonAnonTest extends EntityTestMapFieldResourceTestBase {
use AnonResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
}

View File

@ -0,0 +1,152 @@
<?php
namespace Drupal\Tests\entity_test\Functional\Rest;
use Drupal\entity_test\Entity\EntityTestMapField;
use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
use Drupal\Tests\Traits\ExpectDeprecationTrait;
use Drupal\user\Entity\User;
abstract class EntityTestMapFieldResourceTestBase extends EntityResourceTestBase {
use BcTimestampNormalizerUnixTestTrait;
use ExpectDeprecationTrait;
/**
* {@inheritdoc}
*/
public static $modules = ['entity_test'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'entity_test_map_field';
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [];
/**
* @var \Drupal\entity_test\Entity\EntityTestMapField
*/
protected $entity;
/**
* The complex nested value to assign to a @FieldType=map field.
*
* @var array
*/
protected static $mapValue = [
'key1' => 'value',
'key2' => 'no, val you',
'π' => 3.14159,
TRUE => 42,
'nested' => [
'bird' => 'robin',
'doll' => 'Russian',
],
];
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer entity_test content']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$entity = EntityTestMapField::create([
'name' => 'Llama',
'type' => 'entity_test_map_field',
'data' => [
static::$mapValue,
],
]);
$entity->setOwnerId(0);
$entity->save();
return $entity;
}
/**
* {@inheritdoc}
*/
protected function getExpectedNormalizedEntity() {
$author = User::load(0);
return [
'uuid' => [
[
'value' => $this->entity->uuid(),
],
],
'id' => [
[
'value' => 1,
],
],
'name' => [
[
'value' => 'Llama',
],
],
'langcode' => [
[
'value' => 'en',
],
],
'created' => [
$this->formatExpectedTimestampItemValues((int) $this->entity->get('created')->value),
],
'user_id' => [
[
'target_id' => (int) $author->id(),
'target_type' => 'user',
'target_uuid' => $author->uuid(),
'url' => $author->toUrl()->toString(),
],
],
'data' => [
static::$mapValue,
],
];
}
/**
* {@inheritdoc}
*/
protected function getNormalizedPostEntity() {
return [
'name' => [
[
'value' => 'Dramallama',
],
],
'data' => [
0 => static::$mapValue,
],
];
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
if ($this->config('rest.settings')->get('bc_entity_resource_permissions')) {
return parent::getExpectedUnauthorizedAccessMessage($method);
}
return "The 'administer entity_test content' permission is required.";
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheContexts() {
return ['user.permissions'];
}
}