Skip to content

Commit b80ab9a

Browse files
authored
fix(httpcache): collection iri invalidation for mapped entities (#7353)
fix(httpcache): collection iri invalidation for mapped entities
1 parent 216067d commit b80ab9a

File tree

6 files changed

+89
-17
lines changed

6 files changed

+89
-17
lines changed

src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ final class PurgeHttpCacheListener
4242
private readonly PropertyAccessorInterface $propertyAccessor;
4343
private array $tags = [];
4444

45+
private array $scheduledInsertions = [];
46+
4547
public function __construct(private readonly PurgerInterface $purger,
4648
private readonly IriConverterInterface $iriConverter,
4749
private readonly ResourceClassResolverInterface $resourceClassResolver,
@@ -75,23 +77,16 @@ public function preUpdate(PreUpdateEventArgs $eventArgs): void
7577
}
7678

7779
/**
78-
* Collects tags from inserted and deleted entities, including relations.
80+
* Collects tags from updated and deleted entities, including relations.
7981
*/
8082
public function onFlush(OnFlushEventArgs $eventArgs): void
8183
{
8284
// @phpstan-ignore-next-line
8385
$em = method_exists($eventArgs, 'getObjectManager') ? $eventArgs->getObjectManager() : $eventArgs->getEntityManager();
8486
$uow = $em->getUnitOfWork();
8587

86-
foreach ($uow->getScheduledEntityInsertions() as $entity) {
87-
// For new entities, only purge the collection IRI
88-
try {
89-
if ($this->resourceClassResolver->isResourceClass($this->getObjectClass($entity))) {
90-
$iri = $this->iriConverter->getIriFromResource($entity, UrlGeneratorInterface::ABS_PATH, new GetCollection());
91-
$this->tags[$iri] = $iri;
92-
}
93-
} catch (OperationNotFoundException|InvalidArgumentException) {
94-
}
88+
foreach ($this->scheduledInsertions = $uow->getScheduledEntityInsertions() as $entity) {
89+
// inserts shouldn't add new related entities, we should be able to gather related tags already
9590
$this->gatherRelationTags($em, $entity);
9691
}
9792

@@ -111,6 +106,11 @@ public function onFlush(OnFlushEventArgs $eventArgs): void
111106
*/
112107
public function postFlush(): void
113108
{
109+
// since IRIs can't always be generated for new entities (missing auto-generated IDs), we need to gather the related IRIs after flush()
110+
foreach ($this->scheduledInsertions as $entity) {
111+
$this->gatherResourceAndItemTags($entity, false);
112+
}
113+
114114
if (empty($this->tags)) {
115115
return;
116116
}

src/Symfony/Tests/Doctrine/EventListener/PurgeHttpCacheListenerTest.php

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use ApiPlatform\Metadata\UrlGeneratorInterface;
2323
use ApiPlatform\Symfony\Doctrine\EventListener\PurgeHttpCacheListener;
2424
use ApiPlatform\Symfony\Tests\Fixtures\MappedEntity;
25+
use ApiPlatform\Symfony\Tests\Fixtures\MappedResource;
2526
use ApiPlatform\Symfony\Tests\Fixtures\NotAResource;
2627
use ApiPlatform\Symfony\Tests\Fixtures\TestBundle\Entity\ContainNonResource;
2728
use ApiPlatform\Symfony\Tests\Fixtures\TestBundle\Entity\Dummy;
@@ -35,6 +36,7 @@
3536
use PHPUnit\Framework\TestCase;
3637
use Prophecy\Argument;
3738
use Prophecy\PhpUnit\ProphecyTrait;
39+
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
3840
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
3941

4042
/**
@@ -215,7 +217,8 @@ public function testNotAResourceClass(): void
215217
$propertyAccessorProphecy->getValue(Argument::type(ContainNonResource::class), 'notAResource')->shouldBeCalled()->willReturn($nonResource1);
216218
$propertyAccessorProphecy->getValue(Argument::type(ContainNonResource::class), 'collectionOfNotAResource')->shouldBeCalled()->willReturn($collectionOfNotAResource);
217219

218-
$listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal());
220+
$listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(),
221+
$resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal());
219222
$listener->onFlush($eventArgs);
220223
$listener->postFlush();
221224
}
@@ -229,7 +232,7 @@ public function testAddTagsForCollection(): void
229232
$collection = [$dummy1, $dummy2];
230233

231234
$purgerProphecy = $this->prophesize(PurgerInterface::class);
232-
$purgerProphecy->purge(['/dummies', '/dummies/1', '/dummies/2'])->shouldBeCalled();
235+
$purgerProphecy->purge(['/dummies/1', '/dummies/2', '/dummies'])->shouldBeCalled();
233236

234237
$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
235238
$iriConverterProphecy->getIriFromResource(Argument::type(Dummy::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->willReturn('/dummies')->shouldBeCalled();
@@ -270,15 +273,30 @@ public function testAddTagsForCollection(): void
270273
public function testMappedResources(): void
271274
{
272275
$mappedEntity = new MappedEntity();
276+
$mappedEntity->setFirstName('first');
277+
$mappedEntity->setlastName('last');
278+
279+
$mappedResource = new MappedResource();
280+
$mappedResource->username = $mappedEntity->getFirstName().' '.$mappedEntity->getLastName();
273281

274282
$purgerProphecy = $this->prophesize(PurgerInterface::class);
275283
$purgerProphecy->purge(['/mapped_ressources'])->shouldBeCalled();
276284

277285
$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
278-
$iriConverterProphecy->getIriFromResource(Argument::type(MappedEntity::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->willReturn('/mapped_ressources')->shouldBeCalled();
286+
// the entity is not a resource, shouldn't be called
287+
$iriConverterProphecy->getIriFromResource(
288+
Argument::type(MappedEntity::class), UrlGeneratorInterface::ABS_PATH, new GetCollection()
289+
)->shouldNotBeCalled();
290+
// this should be called instead
291+
$iriConverterProphecy->getIriFromResource(
292+
Argument::type(MappedResource::class), UrlGeneratorInterface::ABS_PATH, new GetCollection()
293+
)->willReturn('/mapped_ressources')->shouldBeCalled();
279294

280295
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
281-
$resourceClassResolverProphecy->isResourceClass(MappedEntity::class)->willReturn(true)->shouldBeCalled();
296+
$resourceClassResolverProphecy->isResourceClass(MappedEntity::class)->willReturn(false)->shouldBeCalled();
297+
298+
$objectMapperProphecy = $this->prophesize(ObjectMapperInterface::class);
299+
$objectMapperProphecy->map($mappedEntity, MappedResource::class)->shouldBeCalled()->willReturn($mappedResource);
282300

283301
$uowProphecy = $this->prophesize(UnitOfWork::class);
284302
$uowProphecy->getScheduledEntityInsertions()->willReturn([$mappedEntity])->shouldBeCalled();
@@ -294,7 +312,10 @@ public function testMappedResources(): void
294312

295313
$propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class);
296314

297-
$listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal());
315+
$listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(),
316+
$resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(),
317+
$objectMapperProphecy->reveal()
318+
);
298319
$listener->onFlush($eventArgs);
299320
$listener->postFlush();
300321
}

src/Symfony/Tests/Fixtures/MappedEntity.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
namespace ApiPlatform\Symfony\Tests\Fixtures;
1515

16-
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResource;
1716
use Doctrine\ORM\Mapping as ORM;
1817
use Symfony\Component\ObjectMapper\Attribute\Map;
1918

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Symfony\Tests\Fixtures;
15+
16+
use ApiPlatform\Doctrine\Orm\State\Options;
17+
use ApiPlatform\JsonLd\ContextBuilder;
18+
use ApiPlatform\Metadata\ApiResource;
19+
use Symfony\Component\ObjectMapper\Attribute\Map;
20+
21+
#[ApiResource(
22+
stateOptions: new Options(entityClass: MappedEntity::class),
23+
normalizationContext: [ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false],
24+
)]
25+
#[Map(target: MappedEntity::class)]
26+
final class MappedResource
27+
{
28+
#[Map(if: false)]
29+
public ?string $id = null;
30+
31+
#[Map(target: 'firstName', transform: [self::class, 'toFirstName'])]
32+
#[Map(target: 'lastName', transform: [self::class, 'toLastName'])]
33+
public string $username;
34+
35+
public static function toFirstName(string $v): string
36+
{
37+
return explode(' ', $v)[0];
38+
}
39+
40+
public static function toLastName(string $v): string
41+
{
42+
return explode(' ', $v)[1];
43+
}
44+
}

src/Symfony/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"symfony/expression-language": "^6.4 || ^7.0",
5959
"symfony/intl": "^6.4 || ^7.0",
6060
"symfony/mercure-bundle": "*",
61+
"symfony/object-mapper": "^7.0",
6162
"symfony/routing": "^6.4 || ^7.0",
6263
"symfony/type-info": "^7.3",
6364
"symfony/validator": "^6.4 || ^7.0",

tests/Behat/HttpCacheContext.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,14 @@ public function irisShouldBePurged(string $iris): void
5050
{
5151
$purger = $this->driverContainer->get('test.api_platform.http_cache.purger');
5252

53-
$purgedIris = implode(',', $purger->getIris());
53+
$iris = explode(',', $iris);
54+
sort($iris);
55+
$iris = implode(',', $iris);
56+
57+
$purgedIris = $purger->getIris();
58+
sort($purgedIris);
59+
$purgedIris = implode(',', $purgedIris);
60+
5461
$purger->clear();
5562

5663
if ($iris !== $purgedIris) {

0 commit comments

Comments
 (0)