diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 54a6ae7c2b4b1..aa045b7533e2c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -188,6 +188,7 @@ use Symfony\Component\Semaphore\SemaphoreFactory; use Symfony\Component\Semaphore\Store\StoreFactory as SemaphoreStoreFactory; use Symfony\Component\Serializer\Attribute as SerializerMapping; +use Symfony\Component\Serializer\Attribute\ExtendsSerializationFor; use Symfony\Component\Serializer\DependencyInjection\AttributeMetadataPass as SerializerAttributeMetadataPass; use Symfony\Component\Serializer\Encoder\DecoderInterface; use Symfony\Component\Serializer\Encoder\EncoderInterface; @@ -2164,6 +2165,11 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder $container->getDefinition('serializer.normalizer.property')->setArgument(5, $defaultContext); $container->setParameter('.serializer.named_serializers', $config['named_serializers'] ?? []); + + $container->registerAttributeForAutoconfiguration(ExtendsSerializationFor::class, function (ChildDefinition $definition, ExtendsSerializationFor $attribute) { + $definition->addTag('serializer.attribute_metadata', ['for' => $attribute->class]) + ->addTag('container.excluded', ['source' => 'because it\'s a serializer metadata extension']); + }); } private function registerJsonStreamerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void diff --git a/src/Symfony/Component/Serializer/Attribute/ExtendsSerializationFor.php b/src/Symfony/Component/Serializer/Attribute/ExtendsSerializationFor.php new file mode 100644 index 0000000000000..d8aa88e0584e8 --- /dev/null +++ b/src/Symfony/Component/Serializer/Attribute/ExtendsSerializationFor.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Attribute; + +/** + * Declares that serialization attributes listed on the current class should be added to the given class. + * + * Classes that use this attribute should contain only properties and methods that + * exist on the target class (not necessarily all of them). + * + * @author Nicolas Grekas + */ +#[\Attribute(\Attribute::TARGET_CLASS)] +final class ExtendsSerializationFor +{ + /** + * @param class-string $class + */ + public function __construct( + public string $class, + ) { + } +} diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 4e17e8877916d..70d8f7c4528eb 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 7.4 --- + * Add `#[ExtendsSerializationFor]` to declare new serialization attributes for a class * Add `AttributeMetadataPass` to declare compile-time constraint metadata using attributes * Add `CDATA_WRAPPING_NAME_PATTERN` support to `XmlEncoder` * Add support for `can*()` methods to `AttributeLoader` diff --git a/src/Symfony/Component/Serializer/DependencyInjection/AttributeMetadataPass.php b/src/Symfony/Component/Serializer/DependencyInjection/AttributeMetadataPass.php index 259c78edbacba..c05c89e86dc25 100644 --- a/src/Symfony/Component/Serializer/DependencyInjection/AttributeMetadataPass.php +++ b/src/Symfony/Component/Serializer/DependencyInjection/AttributeMetadataPass.php @@ -14,6 +14,7 @@ use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\MappingException; /** * @author Nicolas Grekas @@ -35,14 +36,41 @@ public function process(ContainerBuilder $container): void if (!$definition->hasTag('container.excluded')) { throw new InvalidArgumentException(\sprintf('The resource "%s" tagged "serializer.attribute_metadata" is missing the "container.excluded" tag.', $id)); } - $taggedClasses[$resolve($definition->getClass())] = true; + $class = $resolve($definition->getClass()); + foreach ($definition->getTag('serializer.attribute_metadata') as $attributes) { + if ($class !== $for = $attributes['for'] ?? $class) { + $this->checkSourceMapsToTarget($container, $class, $for); + } + + $taggedClasses[$for][$class] = true; + } + } + + if (!$taggedClasses) { + return; } ksort($taggedClasses); - if ($taggedClasses) { - $container->getDefinition('serializer.mapping.attribute_loader') - ->replaceArgument(1, array_keys($taggedClasses)); + $container->getDefinition('serializer.mapping.attribute_loader') + ->replaceArgument(1, array_map('array_keys', $taggedClasses)); + } + + private function checkSourceMapsToTarget(ContainerBuilder $container, string $source, string $target): void + { + $source = $container->getReflectionClass($source); + $target = $container->getReflectionClass($target); + + foreach ($source->getProperties() as $p) { + if ($p->class === $source->name && !($target->hasProperty($p->name) && $target->getProperty($p->name)->class === $target->name)) { + throw new MappingException(\sprintf('The property "%s" on "%s" is not present on "%s".', $p->name, $source->name, $target->name)); + } + } + + foreach ($source->getMethods() as $m) { + if ($m->class === $source->name && !($target->hasMethod($m->name) && $target->getMethod($m->name)->class === $target->name)) { + throw new MappingException(\sprintf('The method "%s" on "%s" is not present on "%s".', $m->name, $source->name, $target->name)); + } } } } diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/AttributeLoader.php b/src/Symfony/Component/Serializer/Mapping/Loader/AttributeLoader.php index f590e6fbc9bef..d6b0b72fda7a1 100644 --- a/src/Symfony/Component/Serializer/Mapping/Loader/AttributeLoader.php +++ b/src/Symfony/Component/Serializer/Mapping/Loader/AttributeLoader.php @@ -44,8 +44,8 @@ class AttributeLoader implements LoaderInterface ]; /** - * @param bool|null $allowAnyClass Null is allowed for BC with Symfony <= 6 - * @param class-string[] $mappedClasses + * @param bool|null $allowAnyClass Null is allowed for BC with Symfony <= 6 + * @param array $mappedClasses */ public function __construct( private ?bool $allowAnyClass = true, @@ -59,16 +59,26 @@ public function __construct( */ public function getMappedClasses(): array { - return $this->mappedClasses; + return array_keys($this->mappedClasses); } public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool { - if (!$this->allowAnyClass && !\in_array($classMetadata->getName(), $this->mappedClasses, true)) { + if (!$sourceClasses = $this->mappedClasses[$classMetadata->getName()] ??= $this->allowAnyClass ? [$classMetadata->getName()] : []) { return false; } - $reflectionClass = $classMetadata->getReflectionClass(); + $success = false; + foreach ($sourceClasses as $sourceClass) { + $reflectionClass = $classMetadata->getName() === $sourceClass ? $classMetadata->getReflectionClass() : new \ReflectionClass($sourceClass); + $success = $this->doLoadClassMetadata($reflectionClass, $classMetadata) || $success; + } + + return $success; + } + + public function doLoadClassMetadata(\ReflectionClass $reflectionClass, ClassMetadataInterface $classMetadata): bool + { $className = $reflectionClass->name; $loaded = false; $classGroups = []; diff --git a/src/Symfony/Component/Serializer/Tests/DependencyInjection/AttributeMetadataPassTest.php b/src/Symfony/Component/Serializer/Tests/DependencyInjection/AttributeMetadataPassTest.php index 0df41b6a7f6d6..edffe3f2c8107 100644 --- a/src/Symfony/Component/Serializer/Tests/DependencyInjection/AttributeMetadataPassTest.php +++ b/src/Symfony/Component/Serializer/Tests/DependencyInjection/AttributeMetadataPassTest.php @@ -13,7 +13,9 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\Serializer\DependencyInjection\AttributeMetadataPass; +use Symfony\Component\Serializer\Exception\MappingException; use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; class AttributeMetadataPassTest extends TestCase @@ -68,7 +70,82 @@ public function testProcessWithTaggedServices() $arguments = $container->getDefinition('serializer.mapping.attribute_loader')->getArguments(); // Classes should be sorted alphabetically - $expectedClasses = ['App\Entity\Order', 'App\Entity\Product', 'App\Entity\User']; + $expectedClasses = [ + 'App\Entity\Order' => ['App\Entity\Order'], + 'App\Entity\Product' => ['App\Entity\Product'], + 'App\Entity\User' => ['App\Entity\User'], + ]; $this->assertSame([false, $expectedClasses], $arguments); } + + public function testThrowsWhenMissingExcludedTag() + { + $container = new ContainerBuilder(); + $container->register('serializer.mapping.attribute_loader'); + + $container->register('service_without_excluded', 'App\\Entity\\User') + ->addTag('serializer.attribute_metadata'); + + $this->expectException(InvalidArgumentException::class); + (new AttributeMetadataPass())->process($container); + } + + public function testProcessWithForOptionAndMatchingMembers() + { + $sourceClass = _AttrMeta_Source::class; + $targetClass = _AttrMeta_Target::class; + + $container = new ContainerBuilder(); + $container->register('serializer.mapping.attribute_loader', AttributeLoader::class) + ->setArguments([false, []]); + + $container->register('service.source', $sourceClass) + ->addTag('serializer.attribute_metadata', ['for' => $targetClass]) + ->addTag('container.excluded'); + + (new AttributeMetadataPass())->process($container); + + $arguments = $container->getDefinition('serializer.mapping.attribute_loader')->getArguments(); + $this->assertSame([false, [$targetClass => [$sourceClass]]], $arguments); + } + + public function testProcessWithForOptionAndMissingMemberThrows() + { + $sourceClass = _AttrMeta_BadSource::class; + $targetClass = _AttrMeta_Target::class; + + $container = new ContainerBuilder(); + $container->register('serializer.mapping.attribute_loader', AttributeLoader::class) + ->setArguments([false, []]); + + $container->register('service.source', $sourceClass) + ->addTag('serializer.attribute_metadata', ['for' => $targetClass]) + ->addTag('container.excluded'); + + $this->expectException(MappingException::class); + (new AttributeMetadataPass())->process($container); + } +} + +class _AttrMeta_Source +{ + public string $name; + + public function getName() + { + } +} + +class _AttrMeta_Target +{ + public string $name; + + public function getName() + { + } +} + +class _AttrMeta_BadSource +{ + public string $extra; } diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AttributeLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AttributeLoaderTest.php index 8ad4d4e062b31..84129bd2283b0 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AttributeLoaderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AttributeLoaderTest.php @@ -249,15 +249,18 @@ public function testIgnoresAccessorishGetters() public function testGetMappedClasses() { - $mappedClasses = ['App\Entity\User', 'App\Entity\Product']; + $mappedClasses = [ + 'App\Entity\User' => ['App\Entity\User'], + 'App\Entity\Product' => ['App\Entity\Product'], + ]; $loader = new AttributeLoader(false, $mappedClasses); - $this->assertSame($mappedClasses, $loader->getMappedClasses()); + $this->assertSame(['App\Entity\User', 'App\Entity\Product'], $loader->getMappedClasses()); } public function testLoadClassMetadataReturnsFalseForUnmappedClass() { - $loader = new AttributeLoader(false, ['App\Entity\User']); + $loader = new AttributeLoader(false, ['App\Entity\User' => ['App\Entity\User']]); $classMetadata = new ClassMetadata('App\Entity\Product'); $this->assertFalse($loader->loadClassMetadata($classMetadata)); @@ -265,15 +268,69 @@ public function testLoadClassMetadataReturnsFalseForUnmappedClass() public function testLoadClassMetadataForMappedClassWithAttributes() { - $loader = new AttributeLoader(false, [GroupDummy::class]); + $loader = new AttributeLoader(false, [GroupDummy::class => [GroupDummy::class]]); $classMetadata = new ClassMetadata(GroupDummy::class); $this->assertTrue($loader->loadClassMetadata($classMetadata)); $this->assertNotEmpty($classMetadata->getAttributesMetadata()); } + public function testLoadClassMetadataFromExplicitAttributeMappings() + { + $targetClass = _AttrMap_Target::class; + $sourceClass = _AttrMap_Source::class; + + $loader = new AttributeLoader(false, [$targetClass => [$sourceClass]]); + $classMetadata = new ClassMetadata($targetClass); + + $this->assertTrue($loader->loadClassMetadata($classMetadata)); + $this->assertContains('default', $classMetadata->getAttributesMetadata()['name']->getGroups()); + } + + public function testLoadClassMetadataWithClassLevelAttributes() + { + $targetClass = _AttrMap_Target::class; + $sourceClass = _AttrMap_ClassLevelSource::class; + + $loader = new AttributeLoader(false, [$targetClass => [$sourceClass]]); + $classMetadata = new ClassMetadata($targetClass); + + $this->assertTrue($loader->loadClassMetadata($classMetadata)); + + // Check that property attributes are added to the target + $this->assertContains('default', $classMetadata->getAttributesMetadata()['name']->getGroups()); + } + protected function getLoaderForContextMapping(): AttributeLoader { return $this->loader; } } + +class _AttrMap_Target +{ + public string $name; + + public function getName() + { + return $this->name; + } +} + +use Symfony\Component\Serializer\Attribute\ExtendsSerializationFor; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ExtendsSerializationFor(_AttrMap_Target::class)] +class _AttrMap_Source +{ + #[Groups(['default'])] + public string $name; +} + +#[ExtendsSerializationFor(_AttrMap_Target::class)] +#[Groups(['class'])] +class _AttrMap_ClassLevelSource +{ + #[Groups(['default'])] + public string $name = ''; +}