Skip to content

Commit ea26095

Browse files
committed
feat(doctrine): improve http cache invalidation using the info from the mapping
1 parent d06b1a0 commit ea26095

File tree

4 files changed

+167
-18
lines changed

4 files changed

+167
-18
lines changed

src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<argument type="service" id="api_platform.iri_converter" />
1414
<argument type="service" id="api_platform.resource_class_resolver" />
1515
<argument type="service" id="api_platform.property_accessor" />
16+
<argument type="service" id="object_mapper" on-invalid="null" />
1617
<tag name="doctrine.event_listener" event="preUpdate" />
1718
<tag name="doctrine.event_listener" event="onFlush" />
1819
<tag name="doctrine.event_listener" event="postFlush" />

src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
use ApiPlatform\HttpCache\PurgerInterface;
1717
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
1818
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
19-
use ApiPlatform\Metadata\Exception\RuntimeException;
2019
use ApiPlatform\Metadata\GetCollection;
2120
use ApiPlatform\Metadata\IriConverterInterface;
2221
use ApiPlatform\Metadata\ResourceClassResolverInterface;
@@ -27,6 +26,8 @@
2726
use Doctrine\ORM\Event\PreUpdateEventArgs;
2827
use Doctrine\ORM\Mapping\AssociationMapping;
2928
use Doctrine\ORM\PersistentCollection;
29+
use Symfony\Component\ObjectMapper\Attribute\Map;
30+
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
3031
use Symfony\Component\PropertyAccess\PropertyAccess;
3132
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
3233

@@ -41,7 +42,11 @@ final class PurgeHttpCacheListener
4142
private readonly PropertyAccessorInterface $propertyAccessor;
4243
private array $tags = [];
4344

44-
public function __construct(private readonly PurgerInterface $purger, private readonly IriConverterInterface $iriConverter, private readonly ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null)
45+
public function __construct(private readonly PurgerInterface $purger,
46+
private readonly IriConverterInterface $iriConverter,
47+
private readonly ResourceClassResolverInterface $resourceClassResolver,
48+
?PropertyAccessorInterface $propertyAccessor = null,
49+
private readonly ?ObjectMapperInterface $objectMapper = null)
4550
{
4651
$this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor();
4752
}
@@ -110,36 +115,47 @@ public function postFlush(): void
110115

111116
private function gatherResourceAndItemTags(object $entity, bool $purgeItem): void
112117
{
113-
try {
114-
$iri = $this->iriConverter->getIriFromResource($entity, UrlGeneratorInterface::ABS_PATH, new GetCollection());
115-
$this->tags[$iri] = $iri;
118+
$resources = $this->getResourcesForEntity($entity);
116119

117-
if ($purgeItem) {
118-
$this->addTagForItem($entity);
120+
foreach ($resources as $resource) {
121+
try {
122+
$iri = $this->iriConverter->getIriFromResource($resource, UrlGeneratorInterface::ABS_PATH, new GetCollection());
123+
$this->tags[$iri] = $iri;
124+
125+
if ($purgeItem) {
126+
$this->addTagForItem($entity);
127+
}
128+
} catch (OperationNotFoundException|InvalidArgumentException) {
119129
}
120-
} catch (OperationNotFoundException|InvalidArgumentException) {
121130
}
122131
}
123132

124133
private function gatherRelationTags(EntityManagerInterface $em, object $entity): void
125134
{
126135
$associationMappings = $em->getClassMetadata($entity::class)->getAssociationMappings();
136+
127137
/** @var array|AssociationMapping $associationMapping according to the version of doctrine orm */
128138
foreach ($associationMappings as $property => $associationMapping) {
129139
if ($associationMapping instanceof AssociationMapping && ($associationMapping->targetEntity ?? null) && !$this->resourceClassResolver->isResourceClass($associationMapping->targetEntity)) {
130140
return;
131141
}
142+
if (!$this->propertyAccessor->isReadable($entity, $property)) {
143+
return;
144+
}
132145

133146
if (
134147
\is_array($associationMapping)
135148
&& \array_key_exists('targetEntity', $associationMapping)
136-
&& !$this->resourceClassResolver->isResourceClass($associationMapping['targetEntity'])) {
149+
&& !$this->resourceClassResolver->isResourceClass($associationMapping['targetEntity'])
150+
&& (
151+
!$this->objectMapper
152+
|| !(new \ReflectionClass($associationMapping['targetEntity']))->getAttributes(Map::class)
153+
)
154+
) {
137155
return;
138156
}
139157

140-
if ($this->propertyAccessor->isReadable($entity, $property)) {
141-
$this->addTagsFor($this->propertyAccessor->getValue($entity, $property));
142-
}
158+
$this->addTagsFor($this->propertyAccessor->getValue($entity, $property));
143159
}
144160
}
145161

@@ -166,14 +182,42 @@ private function addTagsFor(mixed $value): void
166182

167183
private function addTagForItem(mixed $value): void
168184
{
169-
if (!$this->resourceClassResolver->isResourceClass($this->getObjectClass($value))) {
170-
return;
185+
$resources = $this->getResourcesForEntity($value);
186+
187+
foreach ($resources as $resource) {
188+
try {
189+
$iri = $this->iriConverter->getIriFromResource($resource);
190+
$this->tags[$iri] = $iri;
191+
} catch (OperationNotFoundException|InvalidArgumentException) {
192+
}
171193
}
194+
}
172195

173-
try {
174-
$iri = $this->iriConverter->getIriFromResource($value);
175-
$this->tags[$iri] = $iri;
176-
} catch (RuntimeException|InvalidArgumentException) {
196+
private function getResourcesForEntity(object $entity): array
197+
{
198+
$resources = [];
199+
200+
if (!$this->resourceClassResolver->isResourceClass($class = $this->getObjectClass($entity))) {
201+
// is the entity mapped to resource(s)?
202+
if (!$this->objectMapper) {
203+
return [];
204+
}
205+
206+
$mapAttributes = (new \ReflectionClass($class))->getAttributes(Map::class);
207+
208+
if (!$mapAttributes) {
209+
return [];
210+
}
211+
212+
// loop over all mappings to fetch all resources mapped to this entity
213+
$resources = array_map(
214+
fn ($mapAttribute) => $this->objectMapper->map($entity, $mapAttribute->newInstance()->target),
215+
$mapAttributes
216+
);
217+
} else {
218+
$resources[] = $entity;
177219
}
220+
221+
return $resources;
178222
}
179223
}

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use ApiPlatform\Metadata\ResourceClassResolverInterface;
2222
use ApiPlatform\Metadata\UrlGeneratorInterface;
2323
use ApiPlatform\Symfony\Doctrine\EventListener\PurgeHttpCacheListener;
24+
use ApiPlatform\Symfony\Tests\Fixtures\MappedEntity;
2425
use ApiPlatform\Symfony\Tests\Fixtures\NotAResource;
2526
use ApiPlatform\Symfony\Tests\Fixtures\TestBundle\Entity\ContainNonResource;
2627
use ApiPlatform\Symfony\Tests\Fixtures\TestBundle\Entity\Dummy;
@@ -157,6 +158,7 @@ public function testNothingToPurge(): void
157158
$iriConverterProphecy->getIriFromResource($dummyNoGetOperation)->shouldNotBeCalled();
158159

159160
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
161+
$resourceClassResolverProphecy->isResourceClass(DummyNoGetOperation::class)->willReturn(true)->shouldBeCalled();
160162
$resourceClassResolverProphecy->getResourceClass(Argument::type(DummyNoGetOperation::class))->willReturn(DummyNoGetOperation::class)->shouldNotBeCalled();
161163

162164
$emProphecy = $this->prophesize(EntityManagerInterface::class);
@@ -264,4 +266,36 @@ public function testAddTagsForCollection(): void
264266
$listener->onFlush($eventArgs);
265267
$listener->postFlush();
266268
}
269+
270+
public function testMappedResources(): void
271+
{
272+
$mappedEntity = new MappedEntity();
273+
274+
$purgerProphecy = $this->prophesize(PurgerInterface::class);
275+
$purgerProphecy->purge(['/mapped_ressources'])->shouldBeCalled();
276+
277+
$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
278+
$iriConverterProphecy->getIriFromResource(Argument::type(MappedEntity::class), UrlGeneratorInterface::ABS_PATH, new GetCollection())->willReturn('/mapped_ressources')->shouldBeCalled();
279+
280+
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
281+
$resourceClassResolverProphecy->isResourceClass(MappedEntity::class)->willReturn(true)->shouldBeCalled();
282+
283+
$uowProphecy = $this->prophesize(UnitOfWork::class);
284+
$uowProphecy->getScheduledEntityInsertions()->willReturn([$mappedEntity])->shouldBeCalled();
285+
$uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled();
286+
$uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled();
287+
288+
$emProphecy = $this->prophesize(EntityManagerInterface::class);
289+
$emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled();
290+
$classMetadata = new ClassMetadata(MappedEntity::class);
291+
$classMetadata->associationMappings = [];
292+
$emProphecy->getClassMetadata(MappedEntity::class)->willReturn($classMetadata)->shouldBeCalled();
293+
$eventArgs = new OnFlushEventArgs($emProphecy->reveal());
294+
295+
$propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class);
296+
297+
$listener = new PurgeHttpCacheListener($purgerProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal());
298+
$listener->onFlush($eventArgs);
299+
$listener->postFlush();
300+
}
267301
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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\Tests\Fixtures\TestBundle\ApiResource\MappedResource;
17+
use Doctrine\ORM\Mapping as ORM;
18+
use Symfony\Component\ObjectMapper\Attribute\Map;
19+
20+
/**
21+
* MappedEntity to MappedResource.
22+
*/
23+
#[ORM\Entity]
24+
#[Map(target: MappedResource::class)]
25+
class MappedEntity
26+
{
27+
#[ORM\Column(type: 'integer')]
28+
#[ORM\Id]
29+
#[ORM\GeneratedValue(strategy: 'AUTO')]
30+
// @phpstan-ignore-next-line
31+
private ?int $id = null;
32+
33+
#[ORM\Column]
34+
#[Map(if: false)]
35+
private string $firstName;
36+
37+
#[Map(target: 'username', transform: [self::class, 'toUsername'])]
38+
#[ORM\Column]
39+
private string $lastName;
40+
41+
public static function toUsername($value, $object): string
42+
{
43+
return $object->getFirstName().' '.$object->getLastName();
44+
}
45+
46+
public function getId(): ?int
47+
{
48+
return $this->id;
49+
}
50+
51+
public function setLastName(string $name): void
52+
{
53+
$this->lastName = $name;
54+
}
55+
56+
public function getLastName(): string
57+
{
58+
return $this->lastName;
59+
}
60+
61+
public function setFirstName(string $name): void
62+
{
63+
$this->firstName = $name;
64+
}
65+
66+
public function getFirstName(): string
67+
{
68+
return $this->firstName;
69+
}
70+
}

0 commit comments

Comments
 (0)