diff --git a/api/src/HttpCache/PurgeHttpCacheListener.php b/api/src/HttpCache/PurgeHttpCacheListener.php index f46efc9313..219a4b961a 100644 --- a/api/src/HttpCache/PurgeHttpCacheListener.php +++ b/api/src/HttpCache/PurgeHttpCacheListener.php @@ -50,9 +50,6 @@ public function __construct(private readonly IriConverterInterface|LegacyIriConv * Collects tags from the previous and the current version of the updated entities to purge related documents. */ public function preUpdate(PreUpdateEventArgs $eventArgs): void { - $object = $eventArgs->getObject(); - $this->addTagForItem($object); - $changeSet = $eventArgs->getEntityChangeSet(); $objectManager = method_exists($eventArgs, 'getObjectManager') ? $eventArgs->getObjectManager() : $eventArgs->getEntityManager(); $associationMappings = $objectManager->getClassMetadata(ClassUtils::getClass($eventArgs->getObject()))->getAssociationMappings(); @@ -73,7 +70,7 @@ public function preUpdate(PreUpdateEventArgs $eventArgs): void { } /** - * Collects tags from inserted and deleted entities, including relations. + * Collects tags from inserted, updated and deleted entities, including relations. */ public function onFlush(OnFlushEventArgs $eventArgs): void { $em = method_exists($eventArgs, 'getObjectManager') ? $eventArgs->getObjectManager() : $eventArgs->getEntityManager(); @@ -84,10 +81,17 @@ public function onFlush(OnFlushEventArgs $eventArgs): void { $this->gatherRelationTags($em, $entity); } - foreach ($uow->getScheduledEntityDeletions() as $entity) { + foreach ($uow->getScheduledEntityUpdates() as $entity) { + $originalEntity = $this->getOriginalEntity($entity, $em); $this->addTagForItem($entity); - $this->gatherResourceTags($entity); - $this->gatherRelationTags($em, $entity); + $this->gatherResourceTags($entity, $originalEntity); + } + + foreach ($uow->getScheduledEntityDeletions() as $entity) { + $originalEntity = $this->getOriginalEntity($entity, $em); + $this->addTagForItem($originalEntity); + $this->gatherResourceTags($originalEntity); + $this->gatherRelationTags($em, $originalEntity); } } @@ -98,7 +102,30 @@ public function postFlush(): void { $this->cacheManager->flush(); } - private function gatherResourceTags(object $entity): void { + /** + * Computes the original state of the entity based on the current entity and on the changeset. + */ + private function getOriginalEntity($entity, $em) { + $uow = $em->getUnitOfWork(); + $changeSet = $uow->getEntityChangeSet($entity); + $classMetadata = $em->getClassMetadata(ClassUtils::getClass($entity)); + + $originalEntity = clone $entity; + $em->detach($originalEntity); + foreach ($changeSet as $key => $value) { + $classMetadata->setFieldValue($originalEntity, $key, $value[0]); + } + + return $originalEntity; + } + + /** + * Purges all collections (GetCollection operations), in which entity is listed on top level. + * + * If oldEntity is provided, purge is only done if the IRI of the collection has changed + * (e.g. for updating period on a ScheduleEntry and the IRI changes from /periods/1/schedule_entries to /periods/2/schedule_entries) + */ + private function gatherResourceTags(object $entity, ?object $oldEntity = null): void { try { $resourceClass = $this->resourceClassResolver->getResourceClass($entity); $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass); @@ -109,10 +136,7 @@ private function gatherResourceTags(object $entity): void { foreach ($metadata->getOperations() ?? [] as $operation) { if ($operation instanceof GetCollection) { - $iri = $this->iriConverter->getIriFromResource($entity, UrlGeneratorInterface::ABS_PATH, $operation); - if ($iri) { - $this->cacheManager->invalidateTags([$iri]); - } + $this->invalidateCollection($operation, $entity, $oldEntity); } } $resourceIterator->next(); @@ -122,6 +146,34 @@ private function gatherResourceTags(object $entity): void { } /** + * Purges a single collection (GetCollection operation). + * + * If oldEntity is provided, purge is only done if the IRI of the collection has changed + * (e.g. for updating period on a ScheduleEntry and the IRI changes from /periods/1/schedule_entries to /periods/2/schedule_entries) + */ + private function invalidateCollection(GetCollection $operation, object $entity, ?object $oldEntity = null): void { + $iri = $this->iriConverter->getIriFromResource($entity, UrlGeneratorInterface::ABS_PATH, $operation); + + if (!$iri) { + return; + } + + if (!$oldEntity) { + $this->cacheManager->invalidateTags([$iri]); + + return; + } + + $oldIri = $this->iriConverter->getIriFromResource($oldEntity, UrlGeneratorInterface::ABS_PATH, $operation); + if ($iri !== $oldIri) { + $this->cacheManager->invalidateTags([$iri]); + $this->cacheManager->invalidateTags([$oldIri]); + } + } + + /** + * Invalidate all relation tags of foreign objects ($relatedObject), in which $entity appears. + * * @psalm-suppress UndefinedClass */ private function gatherRelationTags(EntityManagerInterface $em, object $entity): void { diff --git a/api/tests/HttpCache/PurgeHttpCacheListenerTest.php b/api/tests/HttpCache/PurgeHttpCacheListenerTest.php index 27188cc703..6ad010fa4e 100644 --- a/api/tests/HttpCache/PurgeHttpCacheListenerTest.php +++ b/api/tests/HttpCache/PurgeHttpCacheListenerTest.php @@ -69,12 +69,31 @@ protected function setUp(): void { $this->uowProphecy = $this->prophesize(UnitOfWork::class); $this->emProphecy = $this->prophesize(EntityManagerInterface::class); + $this->emProphecy->detach(Argument::any())->willReturn(); $this->emProphecy->getUnitOfWork()->willReturn($this->uowProphecy->reveal()); - $dummyClassMetadata = new ClassMetadata(Dummy::class); - $dummyClassMetadata->mapManyToOne(['fieldName' => 'relatedDummy', 'targetEntity' => RelatedDummy::class, 'inversedBy' => 'dummies']); - $dummyClassMetadata->mapOneToOne(['fieldName' => 'relatedOwningDummy', 'targetEntity' => RelatedOwningDummy::class, 'inversedBy' => 'ownedDummy']); - $this->emProphecy->getClassMetadata(Dummy::class)->willReturn($dummyClassMetadata); + $classMetadataProphecy = $this->prophesize(ClassMetadata::class); + $classMetadataProphecy->getAssociationMappings()->willReturn([ + 'relatedDummy' => [ + 'targetEntity' => 'App\\Tests\\HttpCache\\Entity\\RelatedDummy', + 'isOwningSide' => true, + 'inversedBy' => 'dummies', + 'mappedBy' => null, + ], + 'relatedOwningDummy' => [ + 'targetEntity' => 'App\\Tests\\HttpCache\\Entity\\RelatedOwningDummy', + 'isOwningSide' => true, + 'inversedBy' => 'ownedDummy', + 'mappedBy' => null, + ], + ]); + $classMetadataProphecy->setFieldValue(Argument::any(), Argument::any(), Argument::any())->will(function ($args) { + $entity = $args[0]; + $field = $args[1]; + $value = $args[2]; + $entity->{$field} = $value; + }); + $this->emProphecy->getClassMetadata(Dummy::class)->willReturn($classMetadataProphecy->reveal()); $this->propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); $this->propertyAccessorProphecy->isReadable(Argument::type(Dummy::class), 'relatedDummy')->willReturn(true); @@ -148,12 +167,15 @@ public function testOnFlush(): void { $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class)->shouldBeCalled(); $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyNoGetOperation::class))->willReturn(DummyNoGetOperation::class)->shouldBeCalled(); - $uowProphecy = $this->prophesize(UnitOfWork::class); - $uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert1, $toInsert2])->shouldBeCalled(); - $uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete1, $toDelete2, $toDeleteNoPurge])->shouldBeCalled(); + $uowMock = $this->createMock(UnitOfWork::class); + $uowMock->method('getScheduledEntityInsertions')->willReturn([$toInsert1, $toInsert2]); + $uowMock->method('getScheduledEntityUpdates')->willReturn([]); + $uowMock->method('getScheduledEntityDeletions')->willReturn([$toDelete1, $toDelete2, $toDeleteNoPurge]); + $uowMock->method('getEntityChangeSet')->willReturn([]); $emProphecy = $this->prophesize(EntityManagerInterface::class); - $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $emProphecy->getUnitOfWork()->willReturn($uowMock)->shouldBeCalled(); + $emProphecy->detach(Argument::any())->willReturn(); $dummyClassMetadata = new ClassMetadata(Dummy::class); $dummyClassMetadata->mapManyToOne(['fieldName' => 'relatedDummy', 'targetEntity' => RelatedDummy::class, 'inversedBy' => 'dummies']); $dummyClassMetadata->mapOneToOne(['fieldName' => 'relatedOwningDummy', 'targetEntity' => RelatedOwningDummy::class, 'inversedBy' => 'ownedDummy']); @@ -184,7 +206,6 @@ public function testPreUpdate(): void { $dummy->setId('1'); $cacheManagerProphecy = $this->prophesize(CacheManager::class); - $cacheManagerProphecy->invalidateTags(['/dummies/1'])->shouldBeCalled()->willReturn($cacheManagerProphecy); $cacheManagerProphecy->invalidateTags(['/related_dummies/old#dummies'])->shouldBeCalled()->willReturn($cacheManagerProphecy); $cacheManagerProphecy->invalidateTags(['/related_dummies/new#dummies'])->shouldBeCalled()->willReturn($cacheManagerProphecy); $cacheManagerProphecy->flush(Argument::any())->willReturn(0); @@ -192,7 +213,6 @@ public function testPreUpdate(): void { $metadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getIriFromResource($dummy)->willReturn('/dummies/1')->shouldBeCalled(); $iriConverterProphecy->getIriFromResource($oldRelatedDummy)->willReturn('/related_dummies/old')->shouldBeCalled(); $iriConverterProphecy->getIriFromResource($newRelatedDummy)->willReturn('/related_dummies/new')->shouldBeCalled(); @@ -229,10 +249,8 @@ public function testNothingToPurge(): void { $metadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getIriFromResource($dummyNoGetOperation)->willReturn(null)->shouldBeCalled(); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(true)->shouldBeCalled(); $emProphecy = $this->prophesize(EntityManagerInterface::class); @@ -274,6 +292,7 @@ public function testNotAResourceClass(): void { $uowProphecy = $this->prophesize(UnitOfWork::class); $uowProphecy->getScheduledEntityInsertions()->willReturn([$containNonResource])->shouldBeCalled(); $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); $emProphecy = $this->prophesize(EntityManagerInterface::class); $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); @@ -307,6 +326,7 @@ public function testInsertingShouldPurgeSubresourceCollections(): void { $this->uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert1]); $this->uowProphecy->getScheduledEntityDeletions()->willReturn([]); + $this->uowProphecy->getScheduledEntityUpdates()->willReturn([])->shouldBeCalled(); // then $this->cacheManagerProphecy->invalidateTags(['/dummies'])->willReturn($this->cacheManagerProphecy)->shouldBeCalled(); @@ -326,8 +346,13 @@ public function testDeleteShouldPurgeSubresourceCollections(): void { $relatedDummy->setId('100'); $toDelete1->setRelatedDummy($relatedDummy); - $this->uowProphecy->getScheduledEntityInsertions()->willReturn([]); - $this->uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete1]); + $uowMock = $this->createMock(UnitOfWork::class); + $uowMock->method('getScheduledEntityInsertions')->willReturn([]); + $uowMock->method('getScheduledEntityUpdates')->willReturn([]); + $uowMock->method('getScheduledEntityDeletions')->willReturn([$toDelete1]); + $uowMock->method('getEntityChangeSet')->willReturn([]); + + $this->emProphecy->getUnitOfWork()->willReturn($uowMock)->shouldBeCalled(); // then $this->cacheManagerProphecy->invalidateTags(['/dummies/1'])->willReturn($this->cacheManagerProphecy)->shouldBeCalled(); @@ -339,4 +364,34 @@ public function testDeleteShouldPurgeSubresourceCollections(): void { $listener->onFlush(new OnFlushEventArgs($this->emProphecy->reveal())); $listener->postFlush(); } + + public function testUpdateShouldPurgeSubresourceCollections(): void { + // given + $toUpdate1 = new Dummy(); + $toUpdate1->setId('1'); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setId('100'); + $toUpdate1->setRelatedDummy($relatedDummy); + + $relatedDummyOld = new RelatedDummy(); + $relatedDummyOld->setId('99'); + + $uowMock = $this->createMock(UnitOfWork::class); + $uowMock->method('getScheduledEntityInsertions')->willReturn([]); + $uowMock->method('getScheduledEntityUpdates')->willReturn([$toUpdate1]); + $uowMock->method('getScheduledEntityDeletions')->willReturn([]); + $uowMock->method('getEntityChangeSet')->willReturn(['relatedDummy' => [$relatedDummyOld, $relatedDummy]]); + + $this->emProphecy->getUnitOfWork()->willReturn($uowMock)->shouldBeCalled(); + + // then + $this->cacheManagerProphecy->invalidateTags(['/dummies/1'])->willReturn($this->cacheManagerProphecy)->shouldBeCalled(); + $this->cacheManagerProphecy->invalidateTags(['/related_dummies/100/dummies'])->willReturn($this->cacheManagerProphecy)->shouldBeCalled(); + $this->cacheManagerProphecy->invalidateTags(['/related_dummies/99/dummies'])->willReturn($this->cacheManagerProphecy)->shouldBeCalled(); + + // when + $listener = new PurgeHttpCacheListener($this->iriConverterProphecy->reveal(), $this->resourceClassResolverProphecy->reveal(), $this->propertyAccessorProphecy->reveal(), $this->metadataFactoryProphecy->reveal(), $this->cacheManagerProphecy->reveal()); + $listener->onFlush(new OnFlushEventArgs($this->emProphecy->reveal())); + $listener->postFlush(); + } }