From f1fbc1c4029ecc1615dd07720a6e9e5c0deec43d Mon Sep 17 00:00:00 2001 From: mrossard Date: Tue, 26 Aug 2025 15:51:08 +0200 Subject: [PATCH 1/2] fix(httpcache): collection iri invalidation for mapped entities --- .../EventListener/PurgeHttpCacheListener.php | 23 +++++----- .../PurgeHttpCacheListenerTest.php | 29 ++++++++++-- src/Symfony/Tests/Fixtures/MappedEntity.php | 1 - src/Symfony/Tests/Fixtures/MappedResource.php | 44 +++++++++++++++++++ src/Symfony/composer.json | 1 + 5 files changed, 82 insertions(+), 16 deletions(-) create mode 100644 src/Symfony/Tests/Fixtures/MappedResource.php diff --git a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php index bb77e78ea8..2ac1461832 100644 --- a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php +++ b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php @@ -42,6 +42,8 @@ final class PurgeHttpCacheListener private readonly PropertyAccessorInterface $propertyAccessor; private array $tags = []; + private array $scheduledInsertions = []; + public function __construct(private readonly PurgerInterface $purger, private readonly IriConverterInterface $iriConverter, private readonly ResourceClassResolverInterface $resourceClassResolver, @@ -75,7 +77,7 @@ public function preUpdate(PreUpdateEventArgs $eventArgs): void } /** - * Collects tags from inserted and deleted entities, including relations. + * Collects tags from updated and deleted entities, including relations. */ public function onFlush(OnFlushEventArgs $eventArgs): void { @@ -83,15 +85,8 @@ public function onFlush(OnFlushEventArgs $eventArgs): void $em = method_exists($eventArgs, 'getObjectManager') ? $eventArgs->getObjectManager() : $eventArgs->getEntityManager(); $uow = $em->getUnitOfWork(); - foreach ($uow->getScheduledEntityInsertions() as $entity) { - // For new entities, only purge the collection IRI - try { - if ($this->resourceClassResolver->isResourceClass($this->getObjectClass($entity))) { - $iri = $this->iriConverter->getIriFromResource($entity, UrlGeneratorInterface::ABS_PATH, new GetCollection()); - $this->tags[$iri] = $iri; - } - } catch (OperationNotFoundException|InvalidArgumentException) { - } + foreach ($this->scheduledInsertions = $uow->getScheduledEntityInsertions() as $entity) { + // inserts shouldn't add new related entities, we should be able to gather related tags already $this->gatherRelationTags($em, $entity); } @@ -111,6 +106,11 @@ public function onFlush(OnFlushEventArgs $eventArgs): void */ public function postFlush(): void { + // since IRIs can't always be generated for new entities (missing auto-generated IDs), we need to gather the related IRIs after flush() + foreach ($this->scheduledInsertions as $entity) { + $this->gatherResourceAndItemTags($entity, false); + } + if (empty($this->tags)) { return; } @@ -132,7 +132,8 @@ private function gatherResourceAndItemTags(object $entity, bool $purgeItem): voi if ($purgeItem) { $this->addTagForItem($entity); } - } catch (OperationNotFoundException|InvalidArgumentException) { + } catch (OperationNotFoundException|InvalidArgumentException $e) { + dd($e); } } } diff --git a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php index 6413099e0c..4294d92e85 100644 --- a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php +++ b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Symfony\Doctrine\EventListener\PurgeHttpCacheListener; use ApiPlatform\Symfony\Tests\Fixtures\MappedEntity; +use ApiPlatform\Symfony\Tests\Fixtures\MappedResource; use ApiPlatform\Symfony\Tests\Fixtures\NotAResource; use ApiPlatform\Symfony\Tests\Fixtures\TestBundle\Entity\ContainNonResource; use ApiPlatform\Symfony\Tests\Fixtures\TestBundle\Entity\Dummy; @@ -35,6 +36,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; /** @@ -215,7 +217,8 @@ public function testNotAResourceClass(): void $propertyAccessorProphecy->getValue(Argument::type(ContainNonResource::class), 'notAResource')->shouldBeCalled()->willReturn($nonResource1); $propertyAccessorProphecy->getValue(Argument::type(ContainNonResource::class), 'collectionOfNotAResource')->shouldBeCalled()->willReturn($collectionOfNotAResource); - $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal()); + $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal()); $listener->onFlush($eventArgs); $listener->postFlush(); } @@ -270,15 +273,30 @@ public function testAddTagsForCollection(): void public function testMappedResources(): void { $mappedEntity = new MappedEntity(); + $mappedEntity->setFirstName('first'); + $mappedEntity->setlastName('last'); + + $mappedResource = new MappedResource(); + $mappedResource->username = $mappedEntity->getFirstName().' '.$mappedEntity->getLastName(); $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(); + // the entity is not a resource, shouldn't be called + $iriConverterProphecy->getIriFromResource( + Argument::type(MappedEntity::class), UrlGeneratorInterface::ABS_PATH, new GetCollection() + )->shouldNotBeCalled(); + // this should be called instead + $iriConverterProphecy->getIriFromResource( + Argument::type(MappedResource::class), UrlGeneratorInterface::ABS_PATH, new GetCollection() + )->willReturn('/mapped_ressources')->shouldBeCalled(); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->isResourceClass(MappedEntity::class)->willReturn(true)->shouldBeCalled(); + $resourceClassResolverProphecy->isResourceClass(MappedEntity::class)->willReturn(false)->shouldBeCalled(); + + $objectMapperProphecy = $this->prophesize(ObjectMapperInterface::class); + $objectMapperProphecy->map($mappedEntity, MappedResource::class)->shouldBeCalled()->willReturn($mappedResource); $uowProphecy = $this->prophesize(UnitOfWork::class); $uowProphecy->getScheduledEntityInsertions()->willReturn([$mappedEntity])->shouldBeCalled(); @@ -294,7 +312,10 @@ public function testMappedResources(): void $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); - $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal()); + $listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), + $objectMapperProphecy->reveal() + ); $listener->onFlush($eventArgs); $listener->postFlush(); } diff --git a/src/Symfony/Tests/Fixtures/MappedEntity.php b/src/Symfony/Tests/Fixtures/MappedEntity.php index 4be69cff2d..2c81aec610 100644 --- a/src/Symfony/Tests/Fixtures/MappedEntity.php +++ b/src/Symfony/Tests/Fixtures/MappedEntity.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Symfony\Tests\Fixtures; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResource; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\ObjectMapper\Attribute\Map; diff --git a/src/Symfony/Tests/Fixtures/MappedResource.php b/src/Symfony/Tests/Fixtures/MappedResource.php new file mode 100644 index 0000000000..efdf4722e6 --- /dev/null +++ b/src/Symfony/Tests/Fixtures/MappedResource.php @@ -0,0 +1,44 @@ + + * + * 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\Doctrine\Orm\State\Options; +use ApiPlatform\JsonLd\ContextBuilder; +use ApiPlatform\Metadata\ApiResource; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[ApiResource( + stateOptions: new Options(entityClass: MappedEntity::class), + normalizationContext: [ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false], +)] +#[Map(target: MappedEntity::class)] +final class MappedResource +{ + #[Map(if: false)] + public ?string $id = null; + + #[Map(target: 'firstName', transform: [self::class, 'toFirstName'])] + #[Map(target: 'lastName', transform: [self::class, 'toLastName'])] + public string $username; + + public static function toFirstName(string $v): string + { + return explode(' ', $v)[0]; + } + + public static function toLastName(string $v): string + { + return explode(' ', $v)[1]; + } +} diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index 7bda7feb10..a683fe6b15 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -58,6 +58,7 @@ "symfony/expression-language": "^6.4 || ^7.0", "symfony/intl": "^6.4 || ^7.0", "symfony/mercure-bundle": "*", + "symfony/object-mapper": "^7.0", "symfony/routing": "^6.4 || ^7.0", "symfony/type-info": "^7.3", "symfony/validator": "^6.4 || ^7.0", From 3210bfba6263f94207c0a467a01177588374227d Mon Sep 17 00:00:00 2001 From: mrossard Date: Wed, 3 Sep 2025 08:46:41 +0200 Subject: [PATCH 2/2] fix(tests): purged iris may be in random order --- .../Doctrine/EventListener/PurgeHttpCacheListener.php | 3 +-- .../EventListener/PurgeHttpCacheListenerTest.php | 2 +- tests/Behat/HttpCacheContext.php | 9 ++++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php index 2ac1461832..2ebe5c7f74 100644 --- a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php +++ b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php @@ -132,8 +132,7 @@ private function gatherResourceAndItemTags(object $entity, bool $purgeItem): voi if ($purgeItem) { $this->addTagForItem($entity); } - } catch (OperationNotFoundException|InvalidArgumentException $e) { - dd($e); + } catch (OperationNotFoundException|InvalidArgumentException) { } } } diff --git a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php index 4294d92e85..54bdfa6a2c 100644 --- a/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php +++ b/src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php @@ -232,7 +232,7 @@ public function testAddTagsForCollection(): void $collection = [$dummy1, $dummy2]; $purgerProphecy = $this->prophesize(PurgerInterface::class); - $purgerProphecy->purge(['/dummies', '/dummies/1', '/dummies/2'])->shouldBeCalled(); + $purgerProphecy->purge(['/dummies/1', '/dummies/2', '/dummies'])->shouldBeCalled(); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->willReturn('/dummies')->shouldBeCalled(); diff --git a/tests/Behat/HttpCacheContext.php b/tests/Behat/HttpCacheContext.php index 9f889ae33d..d06ba3414e 100644 --- a/tests/Behat/HttpCacheContext.php +++ b/tests/Behat/HttpCacheContext.php @@ -50,7 +50,14 @@ public function irisShouldBePurged(string $iris): void { $purger = $this->driverContainer->get('test.api_platform.http_cache.purger'); - $purgedIris = implode(',', $purger->getIris()); + $iris = explode(',', $iris); + sort($iris); + $iris = implode(',', $iris); + + $purgedIris = $purger->getIris(); + sort($purgedIris); + $purgedIris = implode(',', $purgedIris); + $purger->clear(); if ($iris !== $purgedIris) {