Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
final class ExtendsSerializationFor
{
/**
* @param class-string $class
*/
public function __construct(
public string $class,
) {
}
}
1 change: 1 addition & 0 deletions src/Symfony/Component/Serializer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <p@tchwork.com>
Expand All @@ -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));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<class-string, class-string[]> $mappedClasses
*/
public function __construct(
private ?bool $allowAnyClass = true,
Expand All @@ -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 = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -249,31 +249,88 @@ 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));
}

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