Skip to content

Commit d0e4db4

Browse files
[Serializer] Add #[ExtendsSerializationFor] to declare new serialization attributes for a class
1 parent 8c3e9c1 commit d0e4db4

File tree

7 files changed

+224
-13
lines changed

7 files changed

+224
-13
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@
186186
use Symfony\Component\Semaphore\Semaphore;
187187
use Symfony\Component\Semaphore\SemaphoreFactory;
188188
use Symfony\Component\Semaphore\Store\StoreFactory as SemaphoreStoreFactory;
189+
use Symfony\Component\Serializer\Attribute\ExtendsSerializationFor;
189190
use Symfony\Component\Serializer\DependencyInjection\AttributeMetadataPass as SerializerAttributeMetadataPass;
190191
use Symfony\Component\Serializer\Encoder\DecoderInterface;
191192
use Symfony\Component\Serializer\Encoder\EncoderInterface;
@@ -2122,6 +2123,11 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder
21222123
$container->getDefinition('serializer.normalizer.property')->setArgument(5, $defaultContext);
21232124

21242125
$container->setParameter('.serializer.named_serializers', $config['named_serializers'] ?? []);
2126+
2127+
$container->registerAttributeForAutoconfiguration(ExtendsSerializationFor::class, function (ChildDefinition $definition, ExtendsSerializationFor $attribute) {
2128+
$definition->addTag('serializer.attribute_metadata', ['for' => $attribute->class])
2129+
->addTag('container.excluded', ['source' => 'because it\'s a serializer metadata extension']);
2130+
});
21252131
}
21262132

21272133
private function registerJsonStreamerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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\Attribute;
13+
14+
/**
15+
* Declares that serialization attributes listed on the current class should be added to the given class.
16+
*
17+
* Classes that use this attribute should contain only properties and methods that
18+
* exist on the target class (not necessarily all of them).
19+
*
20+
* @author Nicolas Grekas <p@tchwork.com>
21+
*/
22+
#[\Attribute(\Attribute::TARGET_CLASS)]
23+
final class ExtendsSerializationFor
24+
{
25+
/**
26+
* @param class-string $class
27+
*/
28+
public function __construct(
29+
public string $class,
30+
) {
31+
}
32+
}

src/Symfony/Component/Serializer/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
7.4
55
---
66

7+
* Add `#[ExtendsSerializationFor]` to declare new serialization attributes for a class
78
* Add `AttributeMetadataPass` to declare compile-time constraint metadata using attributes
89
* Add `CDATA_WRAPPING_NAME_PATTERN` support to `XmlEncoder`
910
* Add support for `can*()` methods to `AttributeLoader`

src/Symfony/Component/Serializer/DependencyInjection/AttributeMetadataPass.php

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
1515
use Symfony\Component\DependencyInjection\ContainerBuilder;
1616
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
17+
use Symfony\Component\Serializer\Exception\MappingException;
1718

1819
/**
1920
* @author Nicolas Grekas <p@tchwork.com>
@@ -35,14 +36,41 @@ public function process(ContainerBuilder $container): void
3536
if (!$definition->hasTag('container.excluded')) {
3637
throw new InvalidArgumentException(\sprintf('The resource "%s" tagged "serializer.attribute_metadata" is missing the "container.excluded" tag.', $id));
3738
}
38-
$taggedClasses[$resolve($definition->getClass())] = true;
39+
$class = $resolve($definition->getClass());
40+
foreach ($definition->getTag('serializer.attribute_metadata') as $attributes) {
41+
if ($class !== $for = $attributes['for'] ?? $class) {
42+
$this->checkSourceMapsToTarget($container, $class, $for);
43+
}
44+
45+
$taggedClasses[$for][$class] = true;
46+
}
47+
}
48+
49+
if (!$taggedClasses) {
50+
return;
3951
}
4052

4153
ksort($taggedClasses);
4254

43-
if ($taggedClasses) {
44-
$container->getDefinition('serializer.mapping.attribute_loader')
45-
->replaceArgument(1, array_keys($taggedClasses));
55+
$container->getDefinition('serializer.mapping.attribute_loader')
56+
->replaceArgument(1, array_map('array_keys', $taggedClasses));
57+
}
58+
59+
private function checkSourceMapsToTarget(ContainerBuilder $container, string $source, string $target): void
60+
{
61+
$source = $container->getReflectionClass($source);
62+
$target = $container->getReflectionClass($target);
63+
64+
foreach ($source->getProperties() as $p) {
65+
if ($p->class === $source->name && !($target->hasProperty($p->name) && $target->getProperty($p->name)->class === $target->name)) {
66+
throw new MappingException(\sprintf('The property "%s" on "%s" is not present on "%s".', $p->name, $source->name, $target->name));
67+
}
68+
}
69+
70+
foreach ($source->getMethods() as $m) {
71+
if ($m->class === $source->name && !($target->hasMethod($m->name) && $target->getMethod($m->name)->class === $target->name)) {
72+
throw new MappingException(\sprintf('The method "%s" on "%s" is not present on "%s".', $m->name, $source->name, $target->name));
73+
}
4674
}
4775
}
4876
}

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ class AttributeLoader implements LoaderInterface
4444
];
4545

4646
/**
47-
* @param class-string[] $mappedClasses
47+
* @param array<class-string, class-string[]> $mappedClasses
4848
*/
4949
public function __construct(
5050
private bool $allowAnyClass = true,
@@ -57,16 +57,26 @@ public function __construct(
5757
*/
5858
public function getMappedClasses(): array
5959
{
60-
return $this->mappedClasses;
60+
return array_keys($this->mappedClasses);
6161
}
6262

6363
public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool
6464
{
65-
if (!$this->allowAnyClass && !\in_array($classMetadata->getName(), $this->mappedClasses, true)) {
65+
if (!$sourceClasses = $this->mappedClasses[$classMetadata->getName()] ??= $this->allowAnyClass ? [$classMetadata->getName()] : []) {
6666
return false;
6767
}
6868

69-
$reflectionClass = $classMetadata->getReflectionClass();
69+
$success = false;
70+
foreach ($sourceClasses as $sourceClass) {
71+
$reflectionClass = $classMetadata->getName() === $sourceClass ? $classMetadata->getReflectionClass() : new \ReflectionClass($sourceClass);
72+
$success = $this->doLoadClassMetadata($reflectionClass, $classMetadata) || $success;
73+
}
74+
75+
return $success;
76+
}
77+
78+
public function doLoadClassMetadata(\ReflectionClass $reflectionClass, ClassMetadataInterface $classMetadata): bool
79+
{
7080
$className = $reflectionClass->name;
7181
$loaded = false;
7282
$classGroups = [];

src/Symfony/Component/Serializer/Tests/DependencyInjection/AttributeMetadataPassTest.php

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Component\DependencyInjection\ContainerBuilder;
16+
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
1617
use Symfony\Component\Serializer\DependencyInjection\AttributeMetadataPass;
18+
use Symfony\Component\Serializer\Exception\MappingException;
1719
use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader;
1820

1921
class AttributeMetadataPassTest extends TestCase
@@ -68,7 +70,82 @@ public function testProcessWithTaggedServices()
6870
$arguments = $container->getDefinition('serializer.mapping.attribute_loader')->getArguments();
6971

7072
// Classes should be sorted alphabetically
71-
$expectedClasses = ['App\Entity\Order', 'App\Entity\Product', 'App\Entity\User'];
73+
$expectedClasses = [
74+
'App\Entity\Order' => ['App\Entity\Order'],
75+
'App\Entity\Product' => ['App\Entity\Product'],
76+
'App\Entity\User' => ['App\Entity\User'],
77+
];
7278
$this->assertSame([false, $expectedClasses], $arguments);
7379
}
80+
81+
public function testThrowsWhenMissingExcludedTag()
82+
{
83+
$container = new ContainerBuilder();
84+
$container->register('serializer.mapping.attribute_loader');
85+
86+
$container->register('service_without_excluded', 'App\\Entity\\User')
87+
->addTag('serializer.attribute_metadata');
88+
89+
$this->expectException(InvalidArgumentException::class);
90+
(new AttributeMetadataPass())->process($container);
91+
}
92+
93+
public function testProcessWithForOptionAndMatchingMembers()
94+
{
95+
$sourceClass = _AttrMeta_Source::class;
96+
$targetClass = _AttrMeta_Target::class;
97+
98+
$container = new ContainerBuilder();
99+
$container->register('serializer.mapping.attribute_loader', AttributeLoader::class)
100+
->setArguments([false, []]);
101+
102+
$container->register('service.source', $sourceClass)
103+
->addTag('serializer.attribute_metadata', ['for' => $targetClass])
104+
->addTag('container.excluded');
105+
106+
(new AttributeMetadataPass())->process($container);
107+
108+
$arguments = $container->getDefinition('serializer.mapping.attribute_loader')->getArguments();
109+
$this->assertSame([false, [$targetClass => [$sourceClass]]], $arguments);
110+
}
111+
112+
public function testProcessWithForOptionAndMissingMemberThrows()
113+
{
114+
$sourceClass = _AttrMeta_BadSource::class;
115+
$targetClass = _AttrMeta_Target::class;
116+
117+
$container = new ContainerBuilder();
118+
$container->register('serializer.mapping.attribute_loader', AttributeLoader::class)
119+
->setArguments([false, []]);
120+
121+
$container->register('service.source', $sourceClass)
122+
->addTag('serializer.attribute_metadata', ['for' => $targetClass])
123+
->addTag('container.excluded');
124+
125+
$this->expectException(MappingException::class);
126+
(new AttributeMetadataPass())->process($container);
127+
}
128+
}
129+
130+
class _AttrMeta_Source
131+
{
132+
public string $name;
133+
134+
public function getName()
135+
{
136+
}
137+
}
138+
139+
class _AttrMeta_Target
140+
{
141+
public string $name;
142+
143+
public function getName()
144+
{
145+
}
146+
}
147+
148+
class _AttrMeta_BadSource
149+
{
150+
public string $extra;
74151
}

src/Symfony/Component/Serializer/Tests/Mapping/Loader/AttributeLoaderTest.php

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,31 +249,88 @@ public function testIgnoresAccessorishGetters()
249249

250250
public function testGetMappedClasses()
251251
{
252-
$mappedClasses = ['App\Entity\User', 'App\Entity\Product'];
252+
$mappedClasses = [
253+
'App\Entity\User' => ['App\Entity\User'],
254+
'App\Entity\Product' => ['App\Entity\Product'],
255+
];
253256
$loader = new AttributeLoader(false, $mappedClasses);
254257

255-
$this->assertSame($mappedClasses, $loader->getMappedClasses());
258+
$this->assertSame(['App\Entity\User', 'App\Entity\Product'], $loader->getMappedClasses());
256259
}
257260

258261
public function testLoadClassMetadataReturnsFalseForUnmappedClass()
259262
{
260-
$loader = new AttributeLoader(false, ['App\Entity\User']);
263+
$loader = new AttributeLoader(false, ['App\Entity\User' => ['App\Entity\User']]);
261264
$classMetadata = new ClassMetadata('App\Entity\Product');
262265

263266
$this->assertFalse($loader->loadClassMetadata($classMetadata));
264267
}
265268

266269
public function testLoadClassMetadataForMappedClassWithAttributes()
267270
{
268-
$loader = new AttributeLoader(false, [GroupDummy::class]);
271+
$loader = new AttributeLoader(false, [GroupDummy::class => [GroupDummy::class]]);
269272
$classMetadata = new ClassMetadata(GroupDummy::class);
270273

271274
$this->assertTrue($loader->loadClassMetadata($classMetadata));
272275
$this->assertNotEmpty($classMetadata->getAttributesMetadata());
273276
}
274277

278+
public function testLoadClassMetadataFromExplicitAttributeMappings()
279+
{
280+
$targetClass = _AttrMap_Target::class;
281+
$sourceClass = _AttrMap_Source::class;
282+
283+
$loader = new AttributeLoader(false, [$targetClass => [$sourceClass]]);
284+
$classMetadata = new ClassMetadata($targetClass);
285+
286+
$this->assertTrue($loader->loadClassMetadata($classMetadata));
287+
$this->assertContains('default', $classMetadata->getAttributesMetadata()['name']->getGroups());
288+
}
289+
290+
public function testLoadClassMetadataWithClassLevelAttributes()
291+
{
292+
$targetClass = _AttrMap_Target::class;
293+
$sourceClass = _AttrMap_ClassLevelSource::class;
294+
295+
$loader = new AttributeLoader(false, [$targetClass => [$sourceClass]]);
296+
$classMetadata = new ClassMetadata($targetClass);
297+
298+
$this->assertTrue($loader->loadClassMetadata($classMetadata));
299+
300+
// Check that property attributes are added to the target
301+
$this->assertContains('default', $classMetadata->getAttributesMetadata()['name']->getGroups());
302+
}
303+
275304
protected function getLoaderForContextMapping(): AttributeLoader
276305
{
277306
return $this->loader;
278307
}
279308
}
309+
310+
class _AttrMap_Target
311+
{
312+
public string $name;
313+
314+
public function getName()
315+
{
316+
return $this->name;
317+
}
318+
}
319+
320+
use Symfony\Component\Serializer\Attribute\ExtendsSerializationFor;
321+
use Symfony\Component\Serializer\Attribute\Groups;
322+
323+
#[ExtendsSerializationFor(_AttrMap_Target::class)]
324+
class _AttrMap_Source
325+
{
326+
#[Groups(['default'])]
327+
public string $name;
328+
}
329+
330+
#[ExtendsSerializationFor(_AttrMap_Target::class)]
331+
#[Groups(['class'])]
332+
class _AttrMap_ClassLevelSource
333+
{
334+
#[Groups(['default'])]
335+
public string $name = '';
336+
}

0 commit comments

Comments
 (0)