Skip to content

[Serializer] Allow to add groups to SerializedName annotation/attribute #46432

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: 7.4
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions UPGRADE-6.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ Translation
* Deprecate `PhpExtractor` in favor of `PhpAstExtractor`
* Add `PhpAstExtractor` (requires [nikic/php-parser](https://github.com/nikic/php-parser) to be installed)

Serializer
----------

* Add argument `$groups` to `AttributeMetadata::setSerializedName()` and `AttributeMetadata::getSerializedName()`

Validator
---------

Expand Down
23 changes: 20 additions & 3 deletions src/Symfony/Component/Serializer/Annotation/SerializedName.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,35 @@
*
* @author Fabien Bourigault <bourigaultfabien@gmail.com>
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)]
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
final class SerializedName
{
public function __construct(private string $serializedName)
/**
* @var string[]
*/
private array $groups;

/**
* @param string|string[] $groups
*/
public function __construct(private string $serializedName, string|array $groups = [])
{
$this->groups = (array) $groups;
if ('' === $serializedName) {
throw new InvalidArgumentException(sprintf('Parameter of annotation "%s" must be a non-empty string.', self::class));
throw new InvalidArgumentException(sprintf('Parameter of annotation "%s" must be a non-empty string.', static::class));
}
}

public function getSerializedName(): string
{
return $this->serializedName;
}

/**
* @return string[]
*/
public function getGroups(): array
{
return $this->groups;
}
}
2 changes: 2 additions & 0 deletions src/Symfony/Component/Serializer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ CHANGELOG
* Change the signature of `ClassMetadataInterface::setClassDiscriminatorMapping()` to `setClassDiscriminatorMapping(?ClassDiscriminatorMapping)`
* Add option YamlEncoder::YAML_INDENTATION to YamlEncoder constructor options to configure additional indentation for each level of nesting. This allows configuring indentation in the service configuration.
* Add `SerializedPath` annotation to flatten nested attributes
* Add serialized name group support
* Add argument `$groups` to `AttributeMetadata::setSerializedName()` and `AttributeMetadata::getSerializedName()`

6.1
---
Expand Down
74 changes: 67 additions & 7 deletions src/Symfony/Component/Serializer/Mapping/AttributeMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ class AttributeMetadata implements AttributeMetadataInterface
public $maxDepth;

/**
* @var string|null
* @var array<string, string|null> An array of serialized names by group
*
* @internal This property is public in order to reduce the size of the
* class' serialized representation. Do not access it. Use
* {@link getSerializedName()} instead.
* {@link getSerializedNames()} instead.
*/
public $serializedName;
public $serializedName = [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For maximum BC, I think we should keep this as a string when possible, and use arrays only when actually required.

Copy link
Contributor Author

@alamirault alamirault Oct 26, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nicolas-grekas , Tu be sure, somthing like this ?

/**
* string|array<string, string|null>
*/
public $serializedName;

And deal with string and array in every methods, is it right ?


/**
* @internal This property is public in order to reduce the size of the
Expand Down Expand Up @@ -116,20 +116,68 @@ public function getMaxDepth(): ?int
return $this->maxDepth;
}

public function setSerializedName(string $serializedName = null)
public function setSerializedNames(array $serializedNames): void
{
$this->serializedName = $serializedNames;
}

/**
* Set a serialization name for given groups.
*
* @param string[] $groups
*/
public function setSerializedName(string $serializedName = null/* , array $groups = [] */)
{
if (1 > \func_num_args()) {
trigger_deprecation('symfony/serializer', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__);
}

if (\func_num_args() < 2) {
$groups = [];
} else {
$groups = func_get_arg(1);

if (!\is_array($groups)) {
throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be array, "%s" given.', __METHOD__, get_debug_type($groups)));
}
}

$this->serializedName = $serializedName;
foreach ($groups ?: ['*'] as $group) {
$this->serializedName[$group] = $serializedName;
}
}

public function getSerializedName(): ?string
public function getSerializedNames(): array
{
return $this->serializedName;
}

/**
* Gets the serialization name for given groups.
*
* @param string[] $groups
*/
public function getSerializedName(/* array $groups = [] */): ?string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why pass an array here? Shouldn't we accept string|null instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used by MetadataAwareNameConverter
https://github.com/symfony/symfony/pull/46432/files#diff-87ffe277c0213fca753a8e0b26cc8f4436e255b9776e5e08852c403df247c5b9R121

Serializer group context can be multiple. So returned SerializerName is the first matching group list.

Should be done in MetadataAwareNameConverter ?

{
if (\func_num_args() < 1) {
$groups = [];
} else {
$groups = func_get_arg(0);

if (!\is_array($groups)) {
throw new \TypeError(sprintf('Argument 1 passed to "%s()" must be array, "%s" given.', __METHOD__, get_debug_type($groups)));
}
}

foreach ($groups as $group) {
if (isset($this->serializedName[$group])) {
return $this->serializedName[$group];
}
}

return $this->serializedName['*'] ?? null;
}

public function setSerializedPath(PropertyPath $serializedPath = null): void
{
$this->serializedPath = $serializedPath;
Expand Down Expand Up @@ -210,7 +258,7 @@ public function merge(AttributeMetadataInterface $attributeMetadata)

// Overwrite only if not defined
$this->maxDepth ??= $attributeMetadata->getMaxDepth();
$this->serializedName ??= $attributeMetadata->getSerializedName();
$this->serializedName ??= $attributeMetadata->getSerializedNames();
$this->serializedPath ??= $attributeMetadata->getSerializedPath();

// Overwrite only if both contexts are empty
Expand All @@ -233,4 +281,16 @@ public function __sleep(): array
{
return ['name', 'groups', 'maxDepth', 'serializedName', 'serializedPath', 'ignore', 'normalizationContexts', 'denormalizationContexts'];
}

public function __wakeup()
{
// Preserve compatibility with existing serialized payloads
if (null === $this->serializedName) {
$this->serializedName = [];
} elseif (\is_string($this->serializedName)) {
$this->serializedName = [
'*' => $this->serializedName,
];
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,32 @@ public function setMaxDepth(?int $maxDepth);
public function getMaxDepth(): ?int;

/**
* Sets the serialization name for this attribute.
* Sets the serialization names for given groups.
*
* @param array<string, string|null> $serializedNames
*/
public function setSerializedNames(array $serializedNames): void;

/**
* Set a serialization name for given groups.
*
* @param string[] $groups
*/
public function setSerializedName(?string $serializedName);
public function setSerializedName(?string $serializedName/* , array $groups = [] */);

/**
* Gets the serialization name for this attribute.
* Gets the serialization name for given groups.
*
* @param string[] $groups
*/
public function getSerializedName(/* array $groups = [] */): ?string;

/**
* Gets all the serialization names per group ("*" being the default serialization name).
*
* @return array<string, string|null>
*/
public function getSerializedName(): ?string;
public function getSerializedNames(): array;

public function setSerializedPath(?PropertyPath $serializedPath): void;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ private function generateDeclaredClassMetadata(array $classMetadatas): string
$attributesMetadata[$attributeMetadata->getName()] = [
$attributeMetadata->getGroups(),
$attributeMetadata->getMaxDepth(),
$attributeMetadata->getSerializedName(),
$attributeMetadata->getSerializedNames(),
$attributeMetadata->getSerializedPath(),
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool
} elseif ($annotation instanceof MaxDepth) {
$attributesMetadata[$property->name]->setMaxDepth($annotation->getMaxDepth());
} elseif ($annotation instanceof SerializedName) {
$attributesMetadata[$property->name]->setSerializedName($annotation->getSerializedName());
$attributesMetadata[$property->name]->setSerializedName($annotation->getSerializedName(), $annotation->getGroups());
} elseif ($annotation instanceof SerializedPath) {
$attributesMetadata[$property->name]->setSerializedPath($annotation->getSerializedPath());
} elseif ($annotation instanceof Ignore) {
Expand Down Expand Up @@ -137,7 +137,7 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool
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());
$attributeMetadata->setSerializedName($annotation->getSerializedName(), $annotation->getGroups());
} elseif ($annotation instanceof SerializedPath) {
if (!$accessorOrMutator) {
throw new MappingException(sprintf('SerializedPath on "%s::%s()" cannot be added. SerializedPath can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,13 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool
}

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

foreach ($attribute->serialized_name as $node) {
$serializedName = (string) $node['name'];
$groups = (array) $node->group;
$attributeMetadata->setSerializedName('' === $serializedName ? null : $serializedName, $groups);
}

if (isset($attribute['serialized-path'])) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,24 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool
}

if (isset($data['serialized_name'])) {
if (!\is_string($data['serialized_name']) || '' === $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()));
}
$serializedNames = $data['serialized_name'];

$attributeMetadata->setSerializedName($data['serialized_name']);
if (\is_string($serializedNames)) {
if ('' === $serializedNames) {
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($serializedNames, []);
} elseif (\is_array($serializedNames)) {
foreach ($serializedNames as $serializedName => $groups) {
if (!\is_string($serializedName) || !$serializedName) {
throw new MappingException(sprintf('The key for "serialized_name" array must be a non-empty string in "%s" for the attribute "%s" of the class "%s".', $this->file, $attribute, $classMetadata->getName()));
}

$attributeMetadata->setSerializedName($serializedName, (array) $groups);
}
} else {
throw new MappingException(sprintf('The "serialized_name" value must be a non-empty string or an array of serialized name/groups in "%s" for the attribute "%s" of the class "%s".', $this->file, $attribute, $classMetadata->getName()));
}
}

if (isset($data['serialized_path'])) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@
<xsd:attribute name="class" type="xsd:string" use="required" />
</xsd:complexType>

<xsd:complexType name="serialized-name">
<xsd:sequence minOccurs="0">
<xsd:element name="group" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
</xsd:sequence>
<xsd:attribute name="name">
<xsd:simpleType>
<xsd:restriction base="xsd:string">
<xsd:minLength value="1" />
</xsd:restriction>
</xsd:simpleType>
</xsd:attribute>
</xsd:complexType>

<xsd:complexType name="attribute">
<xsd:annotation>
<xsd:documentation><![CDATA[
Expand All @@ -65,6 +78,7 @@
<xsd:element name="context" type="context" maxOccurs="unbounded" />
<xsd:element name="normalization_context" type="context" maxOccurs="unbounded" />
<xsd:element name="denormalization_context" type="context" maxOccurs="unbounded" />
<xsd:element name="serialized_name" type="serialized-name" maxOccurs="unbounded" />
</xsd:choice>
<xsd:attribute name="name" type="xsd:string" use="required" />
<xsd:attribute name="max-depth">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public function normalize(string $propertyName, string $class = null, string $fo
}

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

return self::$normalizeCache[$class][$propertyName] ?? $this->normalizeFallback($propertyName, $class, $format, $context);
Expand All @@ -66,7 +66,7 @@ public function denormalize(string $propertyName, string $class = null, string $
return self::$denormalizeCache[$cacheKey][$propertyName] ?? $this->denormalizeFallback($propertyName, $class, $format, $context);
}

private function getCacheValueForNormalization(string $propertyName, string $class): ?string
private function getCacheValueForNormalization(string $propertyName, string $class, array $context): ?string
{
if (!$this->metadataFactory->hasMetadataFor($class)) {
return null;
Expand All @@ -77,11 +77,14 @@ private function getCacheValueForNormalization(string $propertyName, string $cla
return null;
}

if (null !== $attributesMetadata[$propertyName]->getSerializedName() && null !== $attributesMetadata[$propertyName]->getSerializedPath()) {
$groups = (array) ($context[AbstractNormalizer::GROUPS] ?? []);

$serializedName = $attributesMetadata[$propertyName]->getSerializedName($groups);
if (null !== $serializedName && null !== $attributesMetadata[$propertyName]->getSerializedPath()) {
throw new LogicException(sprintf('Found SerializedName and SerializedPath annotations on property "%s" of class "%s".', $propertyName, $class));
}

return $attributesMetadata[$propertyName]->getSerializedName() ?? null;
return $serializedName ?? null;
}

private function normalizeFallback(string $propertyName, string $class = null, string $format = null, array $context = []): string
Expand Down Expand Up @@ -114,23 +117,24 @@ private function getCacheValueForAttributesMetadata(string $class, array $contex

$cache = [];
foreach ($classMetadata->getAttributesMetadata() as $name => $metadata) {
if (null === $metadata->getSerializedName()) {
$contextGroups = (array) ($context[AbstractNormalizer::GROUPS] ?? []);
if (null === $serializedName = $metadata->getSerializedName($contextGroups)) {
continue;
}

if (null !== $metadata->getSerializedName() && null !== $metadata->getSerializedPath()) {
if (null !== $metadata->getSerializedPath()) {
throw new LogicException(sprintf('Found SerializedName and SerializedPath annotations on property "%s" of class "%s".', $name, $class));
}

$groups = $metadata->getGroups();
if (!$groups && ($context[AbstractNormalizer::GROUPS] ?? [])) {
if (!$groups && $contextGroups) {
continue;
}
if ($groups && !array_intersect($groups, (array) ($context[AbstractNormalizer::GROUPS] ?? []))) {
if ($groups && !array_intersect($groups, $contextGroups)) {
continue;
}

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

return $cache;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ public function testNotAStringSerializedNameParameter()
public function testSerializedNameParameters()
{
$maxDepth = new SerializedName('foo');
$this->assertEquals('foo', $maxDepth->getSerializedName());
$this->assertSame('foo', $maxDepth->getSerializedName());
$this->assertSame([], $maxDepth->getGroups());
}

public function testSerializedNameParametersWithArrayGroups()
{
$maxDepth = new SerializedName('foo', ['bar', 'baz']);
$this->assertSame('foo', $maxDepth->getSerializedName());
$this->assertSame(['bar', 'baz'], $maxDepth->getGroups());
}

public function testSerializedNameParametersWithStringGroup()
{
$maxDepth = new SerializedName('foo', 'bar');
$this->assertSame('foo', $maxDepth->getSerializedName());
$this->assertSame(['bar'], $maxDepth->getGroups());
}
}
Loading