diff --git a/src/Doctrine/Common/Metadata/Property/DoctrineDiscriminatorSerializerPropertyMetadataFactory.php b/src/Doctrine/Common/Metadata/Property/DoctrineDiscriminatorSerializerPropertyMetadataFactory.php new file mode 100644 index 0000000000..8dcd1394f2 --- /dev/null +++ b/src/Doctrine/Common/Metadata/Property/DoctrineDiscriminatorSerializerPropertyMetadataFactory.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Common\Metadata\Property; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; +use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface as SerializerClassMetadataFactoryInterface; + +/** + * Makes the serializer groups declared on Doctrine inheritance subclasses (JOINED / SINGLE_TABLE) + * count when deciding whether a relation should be embedded. + * + * The link status of a relation is computed by the serializer property metadata factory from the + * groups declared on the *related* resource class. For a Doctrine inheritance hierarchy the related + * class is the abstract parent, so groups declared only on a discriminator subclass are never seen + * and the relation is emitted as an IRI instead of being embedded. This decorator augments the link + * status using the discriminator map exposed by the Doctrine ClassMetadata, which only the doctrine + * layer has access to. + * + * It runs before the serializer property metadata factory: it sets readableLink/writableLink to true + * when a subclass declares a matching group and leaves them untouched (null) otherwise, so the + * serializer factory keeps its default behavior for every other case. + * + * @author Antoine Bluchet + */ +final class DoctrineDiscriminatorSerializerPropertyMetadataFactory implements PropertyMetadataFactoryInterface +{ + use ResourceClassInfoTrait; + + public function __construct( + private readonly ManagerRegistry $managerRegistry, + private readonly PropertyMetadataFactoryInterface $decorated, + private readonly ?SerializerClassMetadataFactoryInterface $serializerClassMetadataFactory = null, + ?ResourceClassResolverInterface $resourceClassResolver = null, + ) { + $this->resourceClassResolver = $resourceClassResolver; + } + + public function create(string $resourceClass, string $property, array $options = []): ApiProperty + { + $propertyMetadata = $this->decorated->create($resourceClass, $property, $options); + + if (null === $this->serializerClassMetadataFactory || null === $this->resourceClassResolver) { + return $propertyMetadata; + } + + // The link status is only meaningful once at least one serializer group is involved. + [$normalizationGroups, $denormalizationGroups] = $this->getEffectiveSerializerGroups($options); + if (null === $normalizationGroups && null === $denormalizationGroups) { + return $propertyMetadata; + } + + // Only act when the serializer factory has not already decided to embed the relation. + if (true === $propertyMetadata->isReadableLink() && true === $propertyMetadata->isWritableLink()) { + return $propertyMetadata; + } + + $relatedClass = $this->getClassNameFromProperty($propertyMetadata); + if (null === $relatedClass || !$this->isResourceClass($relatedClass)) { + return $propertyMetadata; + } + + $relatedClass = $this->resourceClassResolver->getResourceClass(null, $relatedClass); + + $subclasses = $this->getDiscriminatorSubclasses($relatedClass); + if (!$subclasses) { + return $propertyMetadata; + } + + $subclassGroups = []; + foreach ($subclasses as $subclass) { + $subclassGroups[] = $this->getClassSerializerGroups($subclass); + } + $subclassGroups = array_unique(array_merge(...$subclassGroups)); + + if (!$subclassGroups) { + return $propertyMetadata; + } + + if (null === $propertyMetadata->isReadableLink() && null !== $normalizationGroups && array_intersect($normalizationGroups, $subclassGroups)) { + $propertyMetadata = $propertyMetadata->withReadableLink(true); + } + + if (null === $propertyMetadata->isWritableLink() && null !== $denormalizationGroups && array_intersect($denormalizationGroups, $subclassGroups)) { + $propertyMetadata = $propertyMetadata->withWritableLink(true); + } + + return $propertyMetadata; + } + + /** + * @return class-string[] + */ + private function getDiscriminatorSubclasses(string $resourceClass): array + { + $manager = $this->managerRegistry->getManagerForClass($resourceClass); + if (null === $manager) { + return []; + } + + $classMetadata = $manager->getClassMetadata($resourceClass); + if (!isset($classMetadata->discriminatorMap) || !\is_array($classMetadata->discriminatorMap)) { + return []; + } + + return array_values(array_filter( + $classMetadata->discriminatorMap, + static fn (string $class): bool => $class !== $resourceClass, + )); + } + + /** + * @return array{0: string[]|null, 1: string[]|null} + */ + private function getEffectiveSerializerGroups(array $options): array + { + if (isset($options['serializer_groups'])) { + $groups = (array) $options['serializer_groups']; + + return [$groups, $groups]; + } + + if (\array_key_exists('normalization_groups', $options) && \array_key_exists('denormalization_groups', $options)) { + return [ + null !== $options['normalization_groups'] ? (array) $options['normalization_groups'] : null, + null !== $options['denormalization_groups'] ? (array) $options['denormalization_groups'] : null, + ]; + } + + return [null, null]; + } + + /** + * @return string[] + */ + private function getClassSerializerGroups(string $class): array + { + try { + $serializerClassMetadata = $this->serializerClassMetadataFactory->getMetadataFor($class); + } catch (\InvalidArgumentException) { + return []; + } + + $groups = []; + foreach ($serializerClassMetadata->getAttributesMetadata() as $serializerAttributeMetadata) { + $groups[] = $serializerAttributeMetadata->getGroups(); + } + + return $groups ? array_unique(array_merge(...$groups)) : []; + } +} diff --git a/src/Doctrine/Common/Tests/Metadata/Property/DoctrineDiscriminatorSerializerPropertyMetadataFactoryTest.php b/src/Doctrine/Common/Tests/Metadata/Property/DoctrineDiscriminatorSerializerPropertyMetadataFactoryTest.php new file mode 100644 index 0000000000..f086b27992 --- /dev/null +++ b/src/Doctrine/Common/Tests/Metadata/Property/DoctrineDiscriminatorSerializerPropertyMetadataFactoryTest.php @@ -0,0 +1,155 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Common\Tests\Metadata\Property; + +use ApiPlatform\Doctrine\Common\Metadata\Property\DoctrineDiscriminatorSerializerPropertyMetadataFactory; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\Mapping\ClassMetadata; +use Doctrine\Persistence\ObjectManager; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Mapping\AttributeMetadata as SerializerAttributeMetadata; +use Symfony\Component\Serializer\Mapping\ClassMetadata as SerializerClassMetadata; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface as SerializerClassMetadataFactoryInterface; +use Symfony\Component\TypeInfo\Type; + +final class DoctrineDiscriminatorSerializerPropertyMetadataFactoryTest extends TestCase +{ + use ProphecyTrait; + + public function testItEnablesReadableLinkWhenADiscriminatorSubclassDeclaresAMatchingGroup(): void + { + $factory = $this->createFactory( + propertyMetadata: (new ApiProperty())->withReadable(true)->withNativeType(Type::object(ParentResource::class)), + discriminatorMap: ['a' => ChildA::class, 'b' => ChildB::class], + subclassGroups: [ChildA::class => ['foo'], ChildB::class => ['foo']], + ); + + $result = $factory->create(OwnerResource::class, 'relation', [ + 'normalization_groups' => ['foo'], + 'denormalization_groups' => null, + ]); + + $this->assertTrue($result->isReadableLink()); + } + + public function testItLeavesLinkStatusUntouchedWhenNoSubclassGroupMatches(): void + { + $factory = $this->createFactory( + propertyMetadata: (new ApiProperty())->withReadable(true)->withNativeType(Type::object(ParentResource::class)), + discriminatorMap: ['a' => ChildA::class], + subclassGroups: [ChildA::class => ['bar']], + ); + + $result = $factory->create(OwnerResource::class, 'relation', [ + 'normalization_groups' => ['foo'], + 'denormalization_groups' => null, + ]); + + $this->assertNull($result->isReadableLink()); + } + + public function testItLeavesLinkStatusUntouchedWithoutADiscriminatorMap(): void + { + $factory = $this->createFactory( + propertyMetadata: (new ApiProperty())->withReadable(true)->withNativeType(Type::object(ParentResource::class)), + discriminatorMap: [], + subclassGroups: [], + ); + + $result = $factory->create(OwnerResource::class, 'relation', [ + 'normalization_groups' => ['foo'], + 'denormalization_groups' => null, + ]); + + $this->assertNull($result->isReadableLink()); + } + + public function testItDoesNotDowngradeAnAlreadyEmbeddedRelation(): void + { + $factory = $this->createFactory( + propertyMetadata: (new ApiProperty())->withReadable(true)->withReadableLink(true)->withWritableLink(true)->withNativeType(Type::object(ParentResource::class)), + discriminatorMap: ['a' => ChildA::class], + subclassGroups: [ChildA::class => ['bar']], + ); + + $result = $factory->create(OwnerResource::class, 'relation', [ + 'normalization_groups' => ['foo'], + 'denormalization_groups' => null, + ]); + + $this->assertTrue($result->isReadableLink()); + } + + /** + * @param array $discriminatorMap + * @param array $subclassGroups + */ + private function createFactory(ApiProperty $propertyMetadata, array $discriminatorMap, array $subclassGroups): DoctrineDiscriminatorSerializerPropertyMetadataFactory + { + $decorated = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decorated->create(OwnerResource::class, 'relation', Argument::any())->willReturn($propertyMetadata); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(ParentResource::class)->willReturn(true); + $resourceClassResolver->getResourceClass(null, ParentResource::class)->willReturn(ParentResource::class); + + $classMetadata = $this->prophesize(ClassMetadata::class); + $classMetadata->discriminatorMap = $discriminatorMap; + + $objectManager = $this->prophesize(ObjectManager::class); + $objectManager->getClassMetadata(ParentResource::class)->willReturn($classMetadata->reveal()); + + $managerRegistry = $this->prophesize(ManagerRegistry::class); + $managerRegistry->getManagerForClass(ParentResource::class)->willReturn($objectManager->reveal()); + + $serializerClassMetadataFactory = $this->prophesize(SerializerClassMetadataFactoryInterface::class); + foreach ($subclassGroups as $class => $groups) { + $serializerClassMetadata = new SerializerClassMetadata($class); + foreach ($groups as $i => $group) { + $attribute = new SerializerAttributeMetadata('prop'.$i); + $attribute->addGroup($group); + $serializerClassMetadata->addAttributeMetadata($attribute); + } + $serializerClassMetadataFactory->getMetadataFor($class)->willReturn($serializerClassMetadata); + } + + return new DoctrineDiscriminatorSerializerPropertyMetadataFactory( + $managerRegistry->reveal(), + $decorated->reveal(), + $serializerClassMetadataFactory->reveal(), + $resourceClassResolver->reveal(), + ); + } +} + +class OwnerResource +{ +} + +class ParentResource +{ +} + +class ChildA extends ParentResource +{ +} + +class ChildB extends ParentResource +{ +} diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php index fb63291afb..8e7625bf4a 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php @@ -13,6 +13,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use ApiPlatform\Doctrine\Common\Metadata\Property\DoctrineDiscriminatorSerializerPropertyMetadataFactory; use ApiPlatform\Doctrine\Common\State\PersistProcessor; use ApiPlatform\Doctrine\Common\State\RemoveProcessor; use ApiPlatform\Doctrine\Odm\Extension\FilterExtension; @@ -195,6 +196,15 @@ service('api_platform.doctrine_mongodb.odm.metadata.property.metadata_factory.inner'), ]); + $services->set('api_platform.doctrine_mongodb.odm.metadata.property.metadata_factory.discriminator_serializer', DoctrineDiscriminatorSerializerPropertyMetadataFactory::class) + ->decorate('api_platform.metadata.property.metadata_factory', null, 31) + ->args([ + service('doctrine_mongodb'), + service('api_platform.doctrine_mongodb.odm.metadata.property.metadata_factory.discriminator_serializer.inner'), + service('serializer.mapping.class_metadata_factory')->nullOnInvalid(), + service('api_platform.resource_class_resolver'), + ]); + $services->set('api_platform.doctrine_mongodb.odm.state.collection_provider', CollectionProvider::class) ->args([ service('api_platform.metadata.resource.metadata_collection_factory'), diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.php b/src/Symfony/Bundle/Resources/config/doctrine_orm.php index 8004a51229..13806ccf0f 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.php @@ -13,6 +13,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use ApiPlatform\Doctrine\Common\Metadata\Property\DoctrineDiscriminatorSerializerPropertyMetadataFactory; use ApiPlatform\Doctrine\Common\State\PersistProcessor; use ApiPlatform\Doctrine\Common\State\RemoveProcessor; use ApiPlatform\Doctrine\Orm\Extension\EagerLoadingExtension; @@ -213,6 +214,15 @@ service('api_platform.doctrine.orm.metadata.property.metadata_factory.inner'), ]); + $services->set('api_platform.doctrine.orm.metadata.property.metadata_factory.discriminator_serializer', DoctrineDiscriminatorSerializerPropertyMetadataFactory::class) + ->decorate('api_platform.metadata.property.metadata_factory', null, 31) + ->args([ + service('doctrine'), + service('api_platform.doctrine.orm.metadata.property.metadata_factory.discriminator_serializer.inner'), + service('serializer.mapping.class_metadata_factory')->nullOnInvalid(), + service('api_platform.resource_class_resolver'), + ]); + $services->set('api_platform.doctrine.orm.state.collection_provider', CollectionProvider::class) ->args([ service('api_platform.metadata.resource.metadata_collection_factory'), diff --git a/tests/Fixtures/TestBundle/Entity/Issue8113/BarJoined.php b/tests/Fixtures/TestBundle/Entity/Issue8113/BarJoined.php new file mode 100644 index 0000000000..9af753cf5a --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue8113/BarJoined.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue8113; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource(operations: [new Get()])] +#[ORM\Entity] +#[ORM\InheritanceType('JOINED')] +#[ORM\DiscriminatorColumn(name: 'discr', type: 'string')] +#[ORM\DiscriminatorMap([ + 'a' => BarJoinedA::class, + 'b' => BarJoinedB::class, +])] +abstract class BarJoined +{ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Issue8113/BarJoinedA.php b/tests/Fixtures/TestBundle/Entity/Issue8113/BarJoinedA.php new file mode 100644 index 0000000000..405cf44f4c --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue8113/BarJoinedA.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue8113; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource(operations: [new Get()])] +#[ORM\Entity] +class BarJoinedA extends BarJoined +{ + #[ORM\Column] + #[Groups(['foo'])] + private ?string $y = null; + + public function getY(): ?string + { + return $this->y; + } + + public function setY(?string $y): void + { + $this->y = $y; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Issue8113/BarJoinedB.php b/tests/Fixtures/TestBundle/Entity/Issue8113/BarJoinedB.php new file mode 100644 index 0000000000..7f481517d1 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue8113/BarJoinedB.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue8113; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource(operations: [new Get()])] +#[ORM\Entity] +class BarJoinedB extends BarJoined +{ + #[ORM\Column] + #[Groups(['foo'])] + private ?string $z = null; + + public function getZ(): ?string + { + return $this->z; + } + + public function setZ(?string $z): void + { + $this->z = $z; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Issue8113/Foo.php b/tests/Fixtures/TestBundle/Entity/Issue8113/Foo.php new file mode 100644 index 0000000000..33766618f0 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue8113/Foo.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue8113; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource(operations: [new Get(normalizationContext: ['groups' => ['foo']])])] +#[ORM\Entity] +class Foo +{ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: BarJoined::class)] + #[ORM\JoinColumn(nullable: true)] + #[Groups(['foo'])] + private ?BarJoined $barJoined = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getBarJoined(): ?BarJoined + { + return $this->barJoined; + } + + public function setBarJoined(?BarJoined $barJoined): void + { + $this->barJoined = $barJoined; + } +} diff --git a/tests/Functional/Doctrine/JoinedInheritanceSerializerGroupsTest.php b/tests/Functional/Doctrine/JoinedInheritanceSerializerGroupsTest.php new file mode 100644 index 0000000000..e9a714cada --- /dev/null +++ b/tests/Functional/Doctrine/JoinedInheritanceSerializerGroupsTest.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue8113\BarJoined; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue8113\BarJoinedA; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue8113\BarJoinedB; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue8113\Foo; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class JoinedInheritanceSerializerGroupsTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Foo::class, BarJoined::class, BarJoinedA::class, BarJoinedB::class]; + } + + public function testJoinedInheritanceSubclassGroupsEmbedRelation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with mongodb.'); + } + + $this->recreateSchema([Foo::class, BarJoined::class, BarJoinedA::class, BarJoinedB::class]); + + $manager = $this->getManager(); + $barJoinedA = new BarJoinedA(); + $barJoinedA->setY('y_value'); + $manager->persist($barJoinedA); + + $foo = new Foo(); + $foo->setBarJoined($barJoinedA); + $manager->persist($foo); + $manager->flush(); + + $response = self::createClient()->request('GET', '/foos/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@id' => '/foos/1', + 'barJoined' => [ + '@type' => 'BarJoinedA', + 'y' => 'y_value', + ], + ]); + } +}