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