8000 feature #39399 [Serializer] Allow to provide (de)normalization contex… · symfony/symfony@e2b1d9c · GitHub
[go: up one dir, main page]

Skip to content

Commit e2b1d9c

Browse files
committed
feature #39399 [Serializer] Allow to provide (de)normalization context in mapping (ogizanagi)
This PR was squashed before being merged into the 5.3-dev branch. Discussion ---------- [Serializer] Allow to provide (de)normalization context in mapping | Q | A | ------------- | --- | Branch? | 5.x | Bug fix? | no | New feature? | yes <!-- please update src/**/CHANGELOG.md files --> | Deprecations? | no <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files --> | Tickets | Fix #39039 <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead --> | License | MIT | Doc PR | TODO <!-- required for new features --> As explained in the linked feature request, this brings the ability to configure context on a per-property basis, using Serializer mapping. Considering: ```php use Symfony\Component\Serializer\Annotation as Serializer; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; class Foo { /** * @Serializer\Context({ DateTimeNormalizer::FORMAT_KEY = 'Y-m-d' }) */ public \DateTime $date; public \DateTime $anotherDate; } ``` `$date` will be formatted with a specific format, while `$anotherDate` will use the default configured one (or the one provided in the context while calling `->serialize()` / `->normalize()`). It can also differentiate normalization and denormalization contexts: ```php use Symfony\Component\Serializer\Annotation as Serializer; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; class Foo { /** * @Serializer\Context( * normalizationContext = { DateTimeNormalizer::FORMAT_KEY = 'Y-m-d' }, * denormalizationContext = { DateTimeNormalizer::FORMAT_KEY = \DateTime::COOKIE }, * ) */ public \DateTime $date; } ``` As well as act differently depending on groups: ```php use Symfony\Component\Serializer\Annotation as Serializer; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; class Foo { /** * @Serializer\Groups({ "extended" }) * @Serializer\Context({ DateTimeNormalizer::FORMAT_KEY = \DateTime::RFC3339 }) * @Serializer\Context( * context = { DateTimeNormalizer::FORMAT_KEY = \DateTime::RFC3339_EXTENDED }, * groups = {"extended"}, * ) */ public \DateTime $date; } ``` The annotation can be repeated as much as you want to handle the different cases. Context without groups is always applied first, then context for groups are merged in the provided order. Context provided when calling `->serialize()` / `->normalize()` acts as the defaults for the properties without context provided in the metadata. XML mapping (see tests) is a lot verbose due to the required structure to handle groups. Such metadata contexts are also forwarded to name converters, max depth handlers, callbacks, ... Of course, PHP 8 attributes are also supported: ```php use Symfony\Component\Serializer\Annotation as Serializer; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; class Foo { #[Serializer\Groups(["extended"])] #[Serializer\Context([DateTimeNormalizer::FORMAT_KEY => \DateTime::RFC3339])] #[Serializer\Context( context: [DateTimeNormalizer::FORMAT_KEY => \DateTime::RFC3339_EXTENDED], groups: ["extended"], )] public \DateTime $date; } ``` The PR should be ready for first batch of reviews / discussions. - [x] Make Fabbot happy in 5.2 - [x] Missing `@Context` unit tests - [x] rework xml & phpize values - [x] Fix lowest build issue with annotations => bumped doctrine annotations to 1.7, as for other components Commits ------- 7229fa1 [Serializer] Allow to provide (de)normalization context in mapping
2 parents a4c5edc + 7229fa1 commit e2b1d9c

27 files changed

+1183
-17
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Serializer\Annotation;
13+
14+
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
15+
16+
/**
17+
* Annotation class for @Context().
18+
*
19+
* @Annotation
20+
* @Target({"PROPERTY", "METHOD"})
21+
*
22+
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
23+
*/
24+
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
25+
final class Context
26+
{
27+
private $context;
28+
private $normalizationContext;
29+
private $denormalizationContext;
30+
private $groups;
31+
32+
/**
33+
* @throws InvalidArgumentException
34+
*/
35+
public function __construct(array $options = [], array $context = [], array $normalizationContext = [], array $denormalizationContext = [], array $groups = [])
36+
{
37+
if (!$context) {
38+
if (!array_intersect((array_keys($options)), ['normalizationContext', 'groups', 'context', 'value', 'denormalizationContext'])) {
39+
// gracefully supports context as first, unnamed attribute argument if it cannot be confused with Doctrine-style options
40+
$context = $options;
41+
} else {
42+
// If at least one of the options match, it's likely to be Doctrine-style options. Search for the context inside:
43+
$context = $options['value'] ?? $options['context'] ?? [];
44+
}
45+
}
46+
47+
$normalizationContext = $options['normalizationContext'] ?? $normalizationContext;
48+
$denormalizationContext = $options['denormalizationContext'] ?? $denormalizationContext;
49+
50+
foreach (compact(['context', 'normalizationContext', 'denormalizationContext']) as $key => $value) {
51+
if (!\is_array($value)) {
52+
throw new InvalidArgumentException(sprintf('Option "%s" of annotation "%s" must be an array.', $key, static::class));
53+
}
54+
}
55+
56+
if (!$context && !$normalizationContext && !$denormalizationContext) {
57+
throw new InvalidArgumentException(sprintf('At least one of the "context", "normalizationContext", or "denormalizationContext" options of annotation "%s" must be provided as a non-empty array.', static::class));
58+
}
59+
60+
$groups = (array) ($options['groups'] ?? $groups);
61+
62+
foreach ($groups as $group) {
63+
if (!\is_string($group)) {
64+
throw new InvalidArgumentException(sprintf('Parameter "groups" of annotation "%s" must be a string or an array of strings. Got "%s".', static::class, get_debug_type($group)));
65+
}
66+
}
67+
68+
$this->context = $context;
69+
$this->normalizationContext = $normalizationContext;
70+
$this->denormalizationContext = $denormalizationContext;
71+
$this->groups = $groups;
72+
}
73+
74+
public function getContext(): array
75+
{
76+
return $this->context;
77+
}
78+
79+
public function getNormalizationContext(): array
80+
{
81+
return $this->normalizationContext;
82+
}
83+
84+
public function getDenormalizationContext(): array
85+
{
86+
return $this->denormalizationContext;
87+
}
88+
89+
public function getGroups(): array
90+
{
91+
return $this->groups;
92+
}
93+
}

src/Symfony/Component/Serializer/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
5.3
55
---
66

7+
* Add the ability to provide (de)normalization context using metadata (e.g. `@Symfony\Component\Serializer\Annotation\Context`)
78
* deprecated `ArrayDenormalizer::setSerializer()`, call `setDenormalizer()` instead.
89
* added normalization formats to `UidNormalizer`
910

src/Symfony/Component/Serializer/Mapping/AttributeMetadata.php

+95-1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,24 @@ class AttributeMetadata implements AttributeMetadataInterface
5959
*/
6060
public $ignore = false;
6161

62+
/**
63+
* @var array[] Normalization contexts per group name ("*" applies to all groups)
64+
*
65+
* @internal This property is public in order to reduce the size of the
66+
* class' serialized representation. Do not access it. Use
67+
* {@link getNormalizationContexts()} instead.
68+
*/
69+
public $normalizationContexts = [];
70+
71+
/**
72+
* @var array[] Denormalization contexts per group name ("*" applies to all groups)
73+
*
74+
* @internal This property is public in order to reduce the size of the
75+
* class' serialized representation. Do not access it. Use
76+
* {@link getDenormalizationContexts()} instead.
77+
*/
78+
public $denormalizationContexts = [];
79+
6280
public function __construct(string $name)
6381
{
6482
$this->name = $name;
@@ -138,6 +156,76 @@ public function isIgnored(): bool
138156
return $this->ignore;
139157
}
140158

159+
/**
160+
* {@inheritdoc}
161+
*/
162+
public function getNormalizationContexts(): array
163+
{
164+
return $this->normalizationContexts;
165+
}
166+
167+
/**
168+
* {@inheritdoc}
169+
*/
170+
public function getNormalizationContextForGroups(array $groups): array
171+
{
172+
$contexts = [];
173+
foreach ($groups as $group) {
174+
$contexts[] = $this->normalizationContexts[$group] ?? [];
175+
}
176+
177+
return array_merge($this->normalizationContexts['*'] ?? [], ...$contexts);
178+
}
179+
180+
/**
181+
* {@inheritdoc}
182+
*/
183+
public function setNormalizationContextForGroups(array $context, array $groups = []): void
184+
{
185+
if (!$groups) {
186+
$this->normalizationContexts['*'] = $context;
187+
}
188+
189+
foreach ($groups as $group) {
190+
$this->normalizationContexts[$group] = $context;
191+
}
192+
}
193+
194+
/**
195+
* {@inheritdoc}
196+
*/
197+
public function getDenormalizationContexts(): array
198+
{
199+
return $this->denormalizationContexts;
200+
}
201+
202+
/**
203+
* {@inheritdoc}
204+
*/
205+
public function getDenormalizationContextForGroups(array $groups): array
206+
{
207+
$contexts = [];
208+
foreach ($groups as $group) {
209+
$contexts[] = $this->denormalizationContexts[$group] ?? [];
210+
}
211+
212+
return array_merge($this->denormalizationContexts['*'] ?? [], ...$contexts);
213+
}
214+
215+
/**
216+
* {@inheritdoc}
217+
*/
218+
public function setDenormalizationContextForGroups(array $context, array $groups = []): void
219+
{
220+
if (!$groups) {
221+
$this->denormalizationContexts['*'] = $context;
222+
}
223+
224+
foreach ($groups as $group) {
225+
$this->denormalizationContexts[$group] = $context;
226+
}
227+
}
228+
141229
/**
142230
* {@inheritdoc}
143231
*/
@@ -157,6 +245,12 @@ public function merge(AttributeMetadataInterface $attributeMetadata)
157245
$this->serializedName = $attributeMetadata->getSerializedName();
158246
}
159247

248+
// Overwrite only if both contexts are empty
249+
if (!$this->normalizationContexts && !$this->denormalizationContexts) {
250+
$this->normalizationContexts = $attributeMetadata->getNormalizationContexts();
251+
$this->denormalizationContexts = $attributeMetadata->getDenormalizationContexts();
252+
}
253+
160254
if ($ignore = $attributeMetadata->isIgnored()) {
161255
$this->ignore = $ignore;
162256
}
@@ -169,6 +263,6 @@ public function merge(AttributeMetadataInterface $attributeMetadata)
169263
*/
170264
public function __sleep()
171265
{
172-
return ['name', 'groups', 'maxDepth', 'serializedName', 'ignore'];
266+
return ['name', 'groups', 'maxDepth', 'serializedName', 'ignore', 'normalizationContexts', 'denormalizationContexts'];
173267
}
174268
}

src/Symfony/Component/Serializer/Mapping/AttributeMetadataInterface.php

+30
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,34 @@ public function isIgnored(): bool;
7575
* Merges an {@see AttributeMetadataInterface} with in the current one.
7676
*/
7777
public function merge(self $attributeMetadata);
78+
79+
/**
80+
* Gets all the normalization contexts per group ("*" being the base context applied to all groups).
81+
*/
82+
public function getNormalizationContexts(): array;
83+
84+
/**
85+
* Gets the computed normalization contexts for given groups.
86+
*/
87+
public function getNormalizationContextForGroups(array $groups): array;
88+
89+
/**
90+
* Sets the normalization context for given groups.
91+
*/
92+
public function setNormalizationContextForGroups(array $context, array $groups = []): void;
93+
94+
/**
95+
* Gets all the denormalization contexts per group ("*" being the base context applied to all groups).
96+
*/
97+
public function getDenormalizationContexts(): array;
98+
99+
/**
100+
* Gets the computed denormalization contexts for given groups.
101+
*/
102+
public function getDenormalizationContextForGroups(array $groups): array;
103+
104+
/**
105+
* Sets the denormalization context for given groups.
106+
*/
107+
public function setDenormalizationContextForGroups(array $context, array $groups = []): void;
78108
}

src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php

+27
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212
namespace Symfony\Component\Serializer\Mapping\Loader;
1313

1414
use Doctrine\Common\Annotations\Reader;
15+
use Symfony\Component\Serializer\Annotation\Context;
1516
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
1617
use Symfony\Component\Serializer\Annotation\Groups;
1718
use Symfony\Component\Serializer\Annotation\Ignore;
1819
use Symfony\Component\Serializer\Annotation\MaxDepth;
1920
use Symfony\Component\Serializer\Annotation\SerializedName;
2021
use Symfony\Component\Serializer\Exception\MappingException;
2122
use Symfony\Component\Serializer\Mapping\AttributeMetadata;
23+
use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface;
2224
use Symfony\Component\Serializer\Mapping\ClassDiscriminatorMapping;
2325
use Symfony\Component\Serializer\Mapping\ClassMetadataInterface;
2426

@@ -36,6 +38,7 @@ class AnnotationLoader implements LoaderInterface
3638
Ignore::class => true,
3739
MaxDepth::class => true,
3840
SerializedName::class => true,
41+
Context::class => true,
3942
];
4043

4144
private $reader;
@@ -83,6 +86,8 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata)
8386
$attributesMetadata[$property->name]->setSerializedName($annotation->getSerializedName());
8487
} elseif ($annotation instanceof Ignore) {
8588
$attributesMetadata[$property->name]->setIgnore(true);
89+
} elseif ($annotation instanceof Context) {
90+
$this->setAttributeContextsForGroups($annotation, $attributesMetadata[$property->name]);
8691
}
8792

8893
$loaded = true;
@@ -130,6 +135,12 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata)
130135
$attributeMetadata->setSerializedName($annotation->getSerializedName());
131136
} elseif ($annotation instanceof Ignore) {
132137
$attributeMetadata->setIgnore(true);
138+
} elseif ($annotation instanceof Context) {
139+
if (!$accessorOrMutator) {
140+
throw new MappingException(sprintf('Context on "%s::%s()" cannot be added. Context can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name));
141+
}
142+
143+
$this->setAttributeContextsForGroups($annotation, $attributeMetadata);
133144
}
134145

135146
$loaded = true;
@@ -166,4 +177,20 @@ public function loadAnnotations(object $reflector): iterable
166177
yield from $this->reader->getPropertyAnnotations($reflector);
167178
}
168179
}
180+
181+
private function setAttributeContextsForGroups(Context $annotation, AttributeMetadataInterface $attributeMetadata): void
182+
{
183+
if ($annotation->getContext()) {
184+
$attributeMetadata->setNormalizationContextForGroups($annotation->getContext(), $annotation->getGroups());
185+
$attributeMetadata->setDenormalizationContextForGroups($annotation->getContext(), $annotation->getGroups());
186+
}
187+
188+
if ($annotation->getNormalizationContext()) {
189+
$attributeMetadata->setNormalizationContextForGroups($annotation->getNormalizationContext(), $annotation->getGroups());
190+
}
191+
192+
if ($annotation->getDenormalizationContext()) {
193+
$attributeMetadata->setDenormalizationContextForGroups($annotation->getDenormalizationContext(), $annotation->getGroups());
194+
}
195+
}
169196
}

src/Symfony/Component/Serializer/Mapping/Loader/XmlFileLoader.php

+44
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,25 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata)
7474
if (isset($attribute['ignore'])) {
7575
$attributeMetadata->setIgnore((bool) $attribute['ignore']);
7676
}
77+
78+
foreach ($attribute->context as $node) {
79+
$groups = (array) $node->group;
80+
$context = $this->parseContext($node->entry);
81+
$attributeMetadata->setNormalizationContextForGroups($context, $groups);
82+
$attributeMetadata->setDenormalizationContextForGroups($context, $groups);
83+
}
84+
85+
foreach ($attribute->normalization_context as $node) {
86+
$groups = (array) $node->group;
87+
$context = $this->parseContext($node->entry);
88+
$attributeMetadata->setNormalizationContextForGroups($context, $groups);
89+
}
90+
91+
foreach ($attribute->denormalization_context as $node) {
92+
$groups = (array) $node->group;
93+
$context = $this->parseContext($node->entry);
94+
$attributeMetadata->setDenormalizationContextForGroups($context, $groups);
95+
}
7796
}
7897

7998
if (isset($xml->{'discriminator-map'})) {
@@ -136,4 +155,29 @@ private function getClassesFromXml(): array
136155

137156
return $classes;
138157
}
158+
159+
private function parseContext(\SimpleXMLElement $nodes): array
160+
{
161+
$context = [];
162+
163+
foreach ($nodes as $node) {
164+
if (\count($node) > 0) {
165+
if (\count($node->entry) > 0) {
166+
$value = $this->parseContext($node->entry);
167+
} else {
168+
$value = [];
169+
}
170+
} else {
171+
$value = XmlUtils::phpize($node);
172+
}
173+
174+
if (isset($node['name'])) {
175+
$context[(string) $node['name']] = $value;
176+
} else {
177+
$context[] = $value;
178+
}
179+
}
180+
181+
return $context;
182+
}
139183
}

0 commit comments

Comments
 (0)
0