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 = '';
+}