Skip to content

feat(doctrine): improve http cache invalidation using the mapping #7319

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<argument type="service" id="api_platform.iri_converter" />
<argument type="service" id="api_platform.resource_class_resolver" />
<argument type="service" id="api_platform.property_accessor" />
<argument type="service" id="object_mapper" on-invalid="null" />
<tag name="doctrine.event_listener" event="preUpdate" />
<tag name="doctrine.event_listener" event="onFlush" />
<tag name="doctrine.event_listener" event="postFlush" />
Expand Down
80 changes: 62 additions & 18 deletions src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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();
}
Expand Down Expand Up @@ -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));
}
}

Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
}
70 changes: 70 additions & 0 deletions src/Symfony/Tests/Fixtures/MappedEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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;
}
}
Loading