diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.xml b/src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.xml index f9ebf0ea2f..2dc560bc7a 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.xml @@ -13,6 +13,7 @@ + diff --git a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php index 7dafbdf672..8760ff432d 100644 --- a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php +++ b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php @@ -16,7 +16,6 @@ use ApiPlatform\HttpCache\PurgerInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Exception\OperationNotFoundException; -use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; @@ -27,6 +26,8 @@ use Doctrine\ORM\Event\PreUpdateEventArgs; use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\PersistentCollection; +use Symfony\Component\ObjectMapper\Attribute\Map; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -41,7 +42,11 @@ final class PurgeHttpCacheListener private readonly PropertyAccessorInterface $propertyAccessor; private array $tags = []; - public function __construct(private readonly PurgerInterface $purger, private readonly IriConverterInterface $iriConverter, private readonly ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null) + public function __construct(private readonly PurgerInterface $purger, + private readonly IriConverterInterface $iriConverter, + private readonly ResourceClassResolverInterface $resourceClassResolver, + ?PropertyAccessorInterface $propertyAccessor = null, + private readonly ?ObjectMapperInterface $objectMapper = null) { $this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor(); } @@ -110,36 +115,47 @@ public function postFlush(): void private function gatherResourceAndItemTags(object $entity, bool $purgeItem): void { - try { - $iri = $this->iriConverter->getIriFromResource($entity, UrlGeneratorInterface::ABS_PATH, new GetCollection()); - $this->tags[$iri] = $iri; + $resources = $this->getResourcesForEntity($entity); - if ($purgeItem) { - $this->addTagForItem($entity); + foreach ($resources as $resource) { + try { + $iri = $this->iriConverter->getIriFromResource($resource, UrlGeneratorInterface::ABS_PATH, new GetCollection()); + $this->tags[$iri] = $iri; + + if ($purgeItem) { + $this->addTagForItem($entity); + } + } catch (OperationNotFoundException|InvalidArgumentException) { } - } catch (OperationNotFoundException|InvalidArgumentException) { } } private function gatherRelationTags(EntityManagerInterface $em, object $entity): void { $associationMappings = $em->getClassMetadata($entity::class)->getAssociationMappings(); + /** @var array|AssociationMapping $associationMapping according to the version of doctrine orm */ foreach ($associationMappings as $property => $associationMapping) { if ($associationMapping instanceof AssociationMapping && ($associationMapping->targetEntity ?? null) && !$this->resourceClassResolver->isResourceClass($associationMapping->targetEntity)) { return; } + if (!$this->propertyAccessor->isReadable($entity, $property)) { + return; + } if ( \is_array($associationMapping) && \array_key_exists('targetEntity', $associationMapping) - && !$this->resourceClassResolver->isResourceClass($associationMapping['targetEntity'])) { + && !$this->resourceClassResolver->isResourceClass($associationMapping['targetEntity']) + && ( + !$this->objectMapper + || !(new \ReflectionClass($associationMapping['targetEntity']))->getAttributes(Map::class) + ) + ) { return; } - if ($this->propertyAccessor->isReadable($entity, $property)) { - $this->addTagsFor($this->propertyAccessor->getValue($entity, $property)); - } + $this->addTagsFor($this->propertyAccessor->getValue($entity, $property)); } } @@ -166,14 +182,42 @@ private function addTagsFor(mixed $value): void private function addTagForItem(mixed $value): void { - if (!$this->resourceClassResolver->isResourceClass($this->getObjectClass($value))) { - return; + $resources = $this->getResourcesForEntity($value); + + foreach ($resources as $resource) { + try { + $iri = $this->iriConverter->getIriFromResource($resource); + $this->tags[$iri] = $iri; + } catch (OperationNotFoundException|InvalidArgumentException) { + } } + } - try { - $iri = $this->iriConverter->getIriFromResource($value); - $this->tags[$iri] = $iri; - } catch (RuntimeException|InvalidArgumentException) { + private function getResourcesForEntity(object $entity): array + { + $resources = []; + + if (!$this->resourceClassResolver->isResourceClass($class = $this->getObjectClass($entity))) { + // is the entity mapped to resource(s)? + if (!$this->objectMapper) { + return []; + } + + $mapAttributes = (new \ReflectionClass($class))->getAttributes(Map::class); + + if (!$mapAttributes) { + return []; + } + + // loop over all mappings to fetch all resources mapped to this entity + $resources = array_map( + fn ($mapAttribute) => $this->objectMapper->map($entity, $mapAttribute->newInstance()->target), + $mapAttributes + ); + } else { + $resources[] = $entity; } + + return $resources; } } diff --git a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php index 9080d1a7a0..6413099e0c 100644 --- a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php +++ b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Symfony\Doctrine\EventListener\PurgeHttpCacheListener; +use ApiPlatform\Symfony\Tests\Fixtures\MappedEntity; use ApiPlatform\Symfony\Tests\Fixtures\NotAResource; use ApiPlatform\Symfony\Tests\Fixtures\TestBundle\Entity\ContainNonResource; use ApiPlatform\Symfony\Tests\Fixtures\TestBundle\Entity\Dummy; @@ -157,6 +158,7 @@ public function testNothingToPurge(): void $iriConverterProphecy->getIriFromResource($dummyNoGetOperation)->shouldNotBeCalled(); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(DummyNoGetOperation::class)->willReturn(true)->shouldBeCalled(); $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyNoGetOperation::class))->willReturn(DummyNoGetOperation::class)->shouldNotBeCalled(); $emProphecy = $this->prophesize(EntityManagerInterface::class); @@ -264,4 +266,36 @@ public function testAddTagsForCollection(): void $listener->onFlush($eventArgs); $listener->postFlush(); } + + public function testMappedResources(): void + { + $mappedEntity = new MappedEntity(); + + $purgerProphecy = $this->prophesize(PurgerInterface::class); + $purgerProphecy->purge(['/mapped_ressources'])->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource(Argument::type(MappedEntity::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->willReturn('/mapped_ressources')->shouldBeCalled(); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(MappedEntity::class)->willReturn(true)->shouldBeCalled(); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([$mappedEntity])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $classMetadata = new ClassMetadata(MappedEntity::class); + $classMetadata->associationMappings = []; + $emProphecy->getClassMetadata(MappedEntity::class)->willReturn($classMetadata)->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + + $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal()); + $listener->onFlush($eventArgs); + $listener->postFlush(); + } } diff --git a/src/Symfony/Tests/Fixtures/MappedEntity.php b/src/Symfony/Tests/Fixtures/MappedEntity.php new file mode 100644 index 0000000000..4be69cff2d --- /dev/null +++ b/src/Symfony/Tests/Fixtures/MappedEntity.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\Symfony\Tests\Fixtures; + +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\ObjectMapper\Attribute\Map; + +/** + * MappedEntity to MappedResource. + */ +#[ORM\Entity] +#[Map(target: MappedResource::class)] +class MappedEntity +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + // @phpstan-ignore-next-line + private ?int $id = null; + + #[ORM\Column] + #[Map(if: false)] + private string $firstName; + + #[Map(target: 'username', transform: [self::class, 'toUsername'])] + #[ORM\Column] + private string $lastName; + + public static function toUsername($value, $object): string + { + return $object->getFirstName().' '.$object->getLastName(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function setLastName(string $name): void + { + $this->lastName = $name; + } + + public function getLastName(): string + { + return $this->lastName; + } + + public function setFirstName(string $name): void + { + $this->firstName = $name; + } + + public function getFirstName(): string + { + return $this->firstName; + } +}