Skip to content

Commit

Permalink
feature #28505 [Serialized] allow configuring the serialized name of …
Browse files Browse the repository at this point in the history
…properties through metadata (fbourigault)

This PR was squashed before being merged into the 4.2-dev branch (closes #28505).

Discussion
----------

[Serialized] allow configuring the serialized name of properties through metadata

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | #15171
| License       | MIT
| Doc PR        | symfony/symfony-docs#10422

This leverage the new `AdvancedNameConverterInterface` interface (#27021) to implement a name converter that relies on metadata. The name to use is configured per property using a `@SerializedName` annotation or the `serialized-name` XML attribute or the `serialized_name` key for YAML.

This was exposed by @dunglas in #19374 (comment).

# Framework integration
For FramworkBundle integration, a ChainNameConverter could be added to allow users to use this name converter with a custom one.

# To do

- [x] add a CHANGELOG.md entry.
- [x] add a fallback.
- [x] add framework integration.
- [x] add local caching to `MetadataAwareNameConverter`.
- [x] add a doc PR.

Commits
-------

d1d1ceb [Serialized] allow configuring the serialized name of properties through metadata
  • Loading branch information
dunglas committed Oct 5, 2018
2 parents 5a0cad2 + d1d1ceb commit 3e7b029
Show file tree
Hide file tree
Showing 22 changed files with 516 additions and 6 deletions.
Expand Up @@ -74,8 +74,8 @@
use Symfony\Component\PropertyAccess\PropertyAccessor;
use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyDescriptionExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyListExtractorInterface;
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
use Symfony\Component\Routing\Loader\AnnotationDirectoryLoader;
Expand Down Expand Up @@ -1363,7 +1363,7 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder
}

if (isset($config['name_converter']) && $config['name_converter']) {
$container->getDefinition('serializer.normalizer.object')->replaceArgument(1, new Reference($config['name_converter']));
$container->getDefinition('serializer.name_converter.metadata_aware')->setArgument(1, new Reference($config['name_converter']));
}

if (isset($config['circular_reference_handler']) && $config['circular_reference_handler']) {
Expand Down
Expand Up @@ -58,7 +58,7 @@

<service id="serializer.normalizer.object" class="Symfony\Component\Serializer\Normalizer\ObjectNormalizer">
<argument type="service" id="serializer.mapping.class_metadata_factory" />
<argument>null</argument> <!-- name converter -->
<argument type="service" id="serializer.name_converter.metadata_aware" />
<argument type="service" id="serializer.property_accessor" />
<argument type="service" id="property_info" on-invalid="ignore" />
<argument type="service" id="serializer.mapping.class_discriminator_resolver" on-invalid="ignore" />
Expand Down Expand Up @@ -119,6 +119,10 @@
<!-- Name converter -->
<service id="serializer.name_converter.camel_case_to_snake_case" class="Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter" />

<service id="serializer.name_converter.metadata_aware" class="Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter" >
<argument type="service" id="serializer.mapping.class_metadata_factory"/>
</service>

<!-- PropertyInfo extractor -->
<service id="property_info.serializer_extractor" class="Symfony\Component\PropertyInfo\Extractor\SerializerExtractor">
<argument type="service" id="serializer.mapping.class_metadata_factory" />
Expand Down
Expand Up @@ -976,7 +976,7 @@ public function testSerializerEnabled()
$this->assertCount(2, $argument);
$this->assertEquals('Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader', $argument[0]->getClass());
$this->assertNull($container->getDefinition('serializer.mapping.class_metadata_factory')->getArgument(1));
$this->assertEquals(new Reference('serializer.name_converter.camel_case_to_snake_case'), $container->getDefinition('serializer.normalizer.object')->getArgument(1));
$this->assertEquals(new Reference('serializer.name_converter.camel_case_to_snake_case'), $container->getDefinition('serializer.name_converter.metadata_aware')->getArgument(1));
$this->assertEquals(new Reference('property_info', ContainerBuilder::IGNORE_ON_INVALID_REFERENCE), $container->getDefinition('serializer.normalizer.object')->getArgument(3));
$this->assertEquals(array('setCircularReferenceHandler', array(new Reference('my.circular.reference.handler'))), $container->getDefinition('serializer.normalizer.object')->getMethodCalls()[0]);
$this->assertEquals(array('setMaxDepthHandler', array(new Reference('my.max.depth.handler'))), $container->getDefinition('serializer.normalizer.object')->getMethodCalls()[1]);
Expand Down
2 changes: 1 addition & 1 deletion src/Symfony/Bundle/FrameworkBundle/composer.json
Expand Up @@ -45,7 +45,7 @@
"symfony/process": "~3.4|~4.0",
"symfony/security-core": "~3.4|~4.0",
"symfony/security-csrf": "~3.4|~4.0",
"symfony/serializer": "^4.1",
"symfony/serializer": "^4.2",
"symfony/stopwatch": "~3.4|~4.0",
"symfony/translation": "~4.2",
"symfony/templating": "~3.4|~4.0",
Expand Down
48 changes: 48 additions & 0 deletions src/Symfony/Component/Serializer/Annotation/SerializedName.php
@@ -0,0 +1,48 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Serializer\Annotation;

use Symfony\Component\Serializer\Exception\InvalidArgumentException;

/**
* Annotation class for @SerializedName().
*
* @Annotation
* @Target({"PROPERTY", "METHOD"})
*
* @author Fabien Bourigault <bourigaultfabien@gmail.com>
*/
final class SerializedName
{
/**
* @var string
*/
private $serializedName;

public function __construct(array $data)
{
if (!isset($data['value'])) {
throw new InvalidArgumentException(sprintf('Parameter of annotation "%s" should be set.', \get_class($this)));
}

if (!\is_string($data['value']) || empty($data['value'])) {
throw new InvalidArgumentException(sprintf('Parameter of annotation "%s" must be a non-empty string.', \get_class($this)));
}

$this->serializedName = $data['value'];
}

public function getSerializedName(): string
{
return $this->serializedName;
}
}
1 change: 1 addition & 0 deletions src/Symfony/Component/Serializer/CHANGELOG.md
Expand Up @@ -22,6 +22,7 @@ CHANGELOG
either `EncoderInterface` or `DecoderInterface`
* added the optional `$objectClassResolver` argument in `AbstractObjectNormalizer`
and `ObjectNormalizer` constructor
* added `MetadataAwareNameConverter` to configure the serialized name of properties through metadata

4.1.0
-----
Expand Down
32 changes: 31 additions & 1 deletion src/Symfony/Component/Serializer/Mapping/AttributeMetadata.php
Expand Up @@ -41,6 +41,15 @@ class AttributeMetadata implements AttributeMetadataInterface
*/
public $maxDepth;

/**
* @var string|null
*
* @internal This property is public in order to reduce the size of the
* class' serialized representation. Do not access it. Use
* {@link getSerializedName()} instead.
*/
public $serializedName;

public function __construct(string $name)
{
$this->name = $name;
Expand Down Expand Up @@ -88,6 +97,22 @@ public function getMaxDepth()
return $this->maxDepth;
}

/**
* {@inheritdoc}
*/
public function setSerializedName(string $serializedName = null)
{
$this->serializedName = $serializedName;
}

/**
* {@inheritdoc}
*/
public function getSerializedName(): ?string
{
return $this->serializedName;
}

/**
* {@inheritdoc}
*/
Expand All @@ -101,6 +126,11 @@ public function merge(AttributeMetadataInterface $attributeMetadata)
if (null === $this->maxDepth) {
$this->maxDepth = $attributeMetadata->getMaxDepth();
}

// Overwrite only if not defined
if (null === $this->serializedName) {
$this->serializedName = $attributeMetadata->getSerializedName();
}
}

/**
Expand All @@ -110,6 +140,6 @@ public function merge(AttributeMetadataInterface $attributeMetadata)
*/
public function __sleep()
{
return array('name', 'groups', 'maxDepth');
return array('name', 'groups', 'maxDepth', 'serializedName');
}
}
Expand Up @@ -57,6 +57,16 @@ public function setMaxDepth($maxDepth);
*/
public function getMaxDepth();

/**
* Sets the serialization name for this attribute.
*/
public function setSerializedName(string $serializedName = null);

/**
* Gets the serialization name for this attribute.
*/
public function getSerializedName(): ?string;

/**
* Merges an {@see AttributeMetadataInterface} with in the current one.
*/
Expand Down
Expand Up @@ -15,6 +15,7 @@
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\MaxDepth;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Serializer\Exception\MappingException;
use Symfony\Component\Serializer\Mapping\AttributeMetadata;
use Symfony\Component\Serializer\Mapping\ClassDiscriminatorMapping;
Expand Down Expand Up @@ -68,6 +69,8 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata)
}
} elseif ($annotation instanceof MaxDepth) {
$attributesMetadata[$property->name]->setMaxDepth($annotation->getMaxDepth());
} elseif ($annotation instanceof SerializedName) {
$attributesMetadata[$property->name]->setSerializedName($annotation->getSerializedName());
}

$loaded = true;
Expand Down Expand Up @@ -107,6 +110,12 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata)
}

$attributeMetadata->setMaxDepth($annotation->getMaxDepth());
} elseif ($annotation instanceof SerializedName) {
if (!$accessorOrMutator) {
throw new MappingException(sprintf('SerializedName on "%s::%s" cannot be added. SerializedName can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name));
}

$attributeMetadata->setSerializedName($annotation->getSerializedName());
}

$loaded = true;
Expand Down
Expand Up @@ -66,6 +66,10 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata)
if (isset($attribute['max-depth'])) {
$attributeMetadata->setMaxDepth((int) $attribute['max-depth']);
}

if (isset($attribute['serialized-name'])) {
$attributeMetadata->setSerializedName((string) $attribute['serialized-name']);
}
}

if (isset($xml->{'discriminator-map'})) {
Expand Down
Expand Up @@ -85,6 +85,14 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata)

$attributeMetadata->setMaxDepth($data['max_depth']);
}

if (isset($data['serialized_name'])) {
if (!\is_string($data['serialized_name']) || empty($data['serialized_name'])) {
throw new MappingException(sprintf('The "serialized_name" value must be a non-empty string in "%s" for the attribute "%s" of the class "%s".', $this->file, $attribute, $classMetadata->getName()));
}

$attributeMetadata->setSerializedName($data['serialized_name']);
}
}
}

Expand Down
Expand Up @@ -71,6 +71,13 @@
</xsd:restriction>
</xsd:simpleType>
</xsd:attribute>
<xsd:attribute name="serialized-name">
<xsd:simpleType>
<xsd:restriction base="xsd:string">
<xsd:minLength value="1" />
</xsd:restriction>
</xsd:simpleType>
</xsd:attribute>
</xsd:complexType>

</xsd:schema>
@@ -0,0 +1,119 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Serializer\NameConverter;

use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;

/**
* @author Fabien Bourigault <bourigaultfabien@gmail.com>
*/
final class MetadataAwareNameConverter implements AdvancedNameConverterInterface
{
private $metadataFactory;

/**
* @var NameConverterInterface|AdvancedNameConverterInterface|null
*/
private $fallbackNameConverter;

private static $normalizeCache = array();

private static $denormalizeCache = array();

private static $attributesMetadataCache = array();

public function __construct(ClassMetadataFactoryInterface $metadataFactory, NameConverterInterface $fallbackNameConverter = null)
{
$this->metadataFactory = $metadataFactory;
$this->fallbackNameConverter = $fallbackNameConverter;
}

/**
* {@inheritdoc}
*/
public function normalize($propertyName, string $class = null, string $format = null, array $context = array())
{
if (null === $class) {
return $this->normalizeFallback($propertyName, $class, $format, $context);
}

if (!isset(self::$normalizeCache[$class][$propertyName])) {
self::$normalizeCache[$class][$propertyName] = $this->getCacheValueForNormalization($propertyName, $class);
}

return self::$normalizeCache[$class][$propertyName] ?? $this->normalizeFallback($propertyName, $class, $format, $context);
}

/**
* {@inheritdoc}
*/
public function denormalize($propertyName, string $class = null, string $format = null, array $context = array())
{
if (null === $class) {
return $this->denormalizeFallback($propertyName, $class, $format, $context);
}

if (!isset(self::$denormalizeCache[$class][$propertyName])) {
self::$denormalizeCache[$class][$propertyName] = $this->getCacheValueForDenormalization($propertyName, $class);
}

return self::$denormalizeCache[$class][$propertyName] ?? $this->denormalizeFallback($propertyName, $class, $format, $context);
}

private function getCacheValueForNormalization(string $propertyName, string $class): ?string
{
if (!$this->metadataFactory->hasMetadataFor($class)) {
return null;
}

return $this->metadataFactory->getMetadataFor($class)->getAttributesMetadata()[$propertyName]->getSerializedName() ?? null;
}

private function normalizeFallback(string $propertyName, string $class = null, string $format = null, array $context = array()): string
{
return $this->fallbackNameConverter ? $this->fallbackNameConverter->normalize($propertyName, $class, $format, $context) : $propertyName;
}

private function getCacheValueForDenormalization(string $propertyName, string $class): ?string
{
if (!isset(self::$attributesMetadataCache[$class])) {
self::$attributesMetadataCache[$class] = $this->getCacheValueForAttributesMetadata($class);
}

return self::$attributesMetadataCache[$class][$propertyName] ?? null;
}

private function denormalizeFallback(string $propertyName, string $class = null, string $format = null, array $context = array()): string
{
return $this->fallbackNameConverter ? $this->fallbackNameConverter->denormalize($propertyName, $class, $format, $context) : $propertyName;
}

private function getCacheValueForAttributesMetadata(string $class): array
{
if (!$this->metadataFactory->hasMetadataFor($class)) {
return array();
}

$classMetadata = $this->metadataFactory->getMetadataFor($class);

$cache = array();
foreach ($classMetadata->getAttributesMetadata() as $name => $metadata) {
if (null === $metadata->getSerializedName()) {
continue;
}

$cache[$metadata->getSerializedName()] = $name;
}

return $cache;
}
}

0 comments on commit 3e7b029

Please sign in to comment.