diff --git a/doc/8-disable-caching-behavior.md b/doc/8-disable-caching-behavior.md index 02771f2..5fead1c 100644 --- a/doc/8-disable-caching-behavior.md +++ b/doc/8-disable-caching-behavior.md @@ -14,8 +14,43 @@ To achieve this, you can use the `CacheActivator` to disable tagging and invalid self::getContainer()->get(CacheActivator::class)->deactivateCaching(); // Your test code here - + self::assertSame('this is amazing!', $result); } ``` +## Suppress automatic tagging for a specific code block + +Sometimes you need to load Pimcore elements for business logic without tagging the current response with those elements. For example, loading related products to calculate a price should not cause the response to be tagged with all those products. + +Use `CacheActivator::withoutAutomaticTagging()` to run a block of code with automatic tagging suppressed: + +```php +$result = $cacheActivator->withoutAutomaticTagging(function () use ($id) { + // Automatic tagging is disabled here — loading this element + // will not tag the current response. + return $this->repository->find($id); +}); +``` + +### Selectively tagging within the block + +If you still want to tag the response with specific tags inside the block, use a generator and `yield` the tags you need: + +```php +$result = $cacheActivator->withoutAutomaticTagging(function () use ($id) { + $element = $this->repository->find($id); + + // Yield only the tags you explicitly want applied to the response. + yield CacheTag::fromElement($element); + + return $element; +}); +``` + +You can yield both `CacheTag` and `CacheTags` objects. All yielded tags are collected and applied to the response after the block completes. The return value of the generator is returned by `withoutAutomaticTagging()`. + +### State restoration + +The previous caching state is always restored after the block completes, even if an exception is thrown. If caching was already disabled before calling `withoutAutomaticTagging()`, it remains disabled afterwards. + diff --git a/src/Cache/ResponseTagger/TraceableResponseTagger.php b/src/Cache/ResponseTagger/TraceableResponseTagger.php index 930382f..9963753 100644 --- a/src/Cache/ResponseTagger/TraceableResponseTagger.php +++ b/src/Cache/ResponseTagger/TraceableResponseTagger.php @@ -1,5 +1,4 @@ -) $fn + * @param \Closure(): (T|\Generator) $fn * * @return T */ @@ -64,7 +64,6 @@ public function withoutAutomaticTagging(\Closure $fn): mixed $tags = $tags->with($yielded); } - $result = $result->getReturn(); } } finally { diff --git a/tests/Integration/Fixtures/without_automatic_tagging_route.php b/tests/Integration/Fixtures/without_automatic_tagging_route.php new file mode 100644 index 0000000..802031d --- /dev/null +++ b/tests/Integration/Fixtures/without_automatic_tagging_route.php @@ -0,0 +1,9 @@ +add('without_automatic_tagging', '/without-automatic-tagging') + ->controller(WithoutAutomaticTaggingController::class); +}; diff --git a/tests/Integration/Tagging/WithoutAutomaticTaggingTest.php b/tests/Integration/Tagging/WithoutAutomaticTaggingTest.php new file mode 100644 index 0000000..49fc740 --- /dev/null +++ b/tests/Integration/Tagging/WithoutAutomaticTaggingTest.php @@ -0,0 +1,63 @@ +client = self::createClient(); + } + + /** + * @test + */ + #[ConfigureExtension('neusta_pimcore_http_cache', [ + 'elements' => [ + 'assets' => true, + ], + ])] + public function response_is_not_tagged_when_asset_is_loaded_without_automatic_tagging(): void + { + self::arrange(static fn () => TestAssetFactory::simpleAsset()->save()); + + $this->client->request('GET', '/without-automatic-tagging?id=42'); + + $response = $this->client->getResponse(); + self::assertSame(200, $response->getStatusCode()); + self::assertNull($response->headers->get('X-Cache-Tags')); + } + + /** + * @test + */ + #[ConfigureExtension('neusta_pimcore_http_cache', [ + 'elements' => [ + 'assets' => true, + ], + ])] + public function response_is_tagged_with_manually_yielded_tag_when_loaded_without_automatic_tagging(): void + { + self::arrange(static fn () => TestAssetFactory::simpleAsset()->save()); + + $this->client->request('GET', '/without-automatic-tagging?id=42&manual_tag=true'); + + $response = $this->client->getResponse(); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('a42', $response->headers->get('X-Cache-Tags')); + } +} diff --git a/tests/Unit/Cache/ResponseTagger/TraceableResponseTaggerTest.php b/tests/Unit/Cache/ResponseTagger/TraceableResponseTaggerTest.php index b7ba2ab..d9db01b 100644 --- a/tests/Unit/Cache/ResponseTagger/TraceableResponseTaggerTest.php +++ b/tests/Unit/Cache/ResponseTagger/TraceableResponseTaggerTest.php @@ -14,7 +14,7 @@ final class TraceableResponseTaggerTest extends TestCase { use ProphecyTrait; - private TraceableResponseTagger $collectTagsResponseTagger; + private TraceableResponseTagger $traceableResponseTagger; /** @var ObjectProphecy */ private ObjectProphecy $innerTagger; @@ -22,7 +22,7 @@ final class TraceableResponseTaggerTest extends TestCase protected function setUp(): void { $this->innerTagger = $this->prophesize(ResponseTagger::class); - $this->collectTagsResponseTagger = new TraceableResponseTagger($this->innerTagger->reveal()); + $this->traceableResponseTagger = new TraceableResponseTagger($this->innerTagger->reveal()); } /** @@ -30,7 +30,7 @@ protected function setUp(): void */ public function tag_should_collect_tags(): void { - $this->collectTagsResponseTagger->tag( + $this->traceableResponseTagger->tag( new CacheTags( CacheTag::fromString('tag1'), CacheTag::fromString('tag2'), @@ -38,7 +38,7 @@ public function tag_should_collect_tags(): void self::assertSame( 'tag1,tag2', - $this->collectTagsResponseTagger->recordedTags->toString(), + $this->traceableResponseTagger->recordedTags->toString(), ); } @@ -52,7 +52,7 @@ public function tag_should_forward_tags_to_inner_tagger(): void CacheTag::fromString('tag2'), ); - $this->collectTagsResponseTagger->tag($tags); + $this->traceableResponseTagger->tag($tags); $this->innerTagger->tag($tags)->shouldHaveBeenCalledOnce(); } @@ -62,16 +62,16 @@ public function tag_should_forward_tags_to_inner_tagger(): void */ public function reset_should_reset_collected_tags(): void { - $this->collectTagsResponseTagger->tag( + $this->traceableResponseTagger->tag( new CacheTags( CacheTag::fromString('tag1'), CacheTag::fromString('tag2'), )); - $this->collectTagsResponseTagger->reset(); + $this->traceableResponseTagger->reset(); self::assertTrue( - $this->collectTagsResponseTagger->recordedTags->isEmpty(), + $this->traceableResponseTagger->recordedTags->isEmpty(), ); } } diff --git a/tests/Unit/CacheActivatorTest.php b/tests/Unit/CacheActivatorTest.php index c0084e5..00a49d1 100644 --- a/tests/Unit/CacheActivatorTest.php +++ b/tests/Unit/CacheActivatorTest.php @@ -2,16 +2,28 @@ namespace Neusta\Pimcore\HttpCacheBundle\Tests\Unit; +use Neusta\Pimcore\HttpCacheBundle\Cache\CacheTag; +use Neusta\Pimcore\HttpCacheBundle\Cache\CacheTags; +use Neusta\Pimcore\HttpCacheBundle\Cache\ResponseTagger; use Neusta\Pimcore\HttpCacheBundle\CacheActivator; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; final class CacheActivatorTest extends TestCase { + use ProphecyTrait; + private CacheActivator $cacheActivator; + /** @var ObjectProphecy */ + private ObjectProphecy $responseTagger; + protected function setUp(): void { - $this->cacheActivator = new CacheActivator(); + $this->responseTagger = $this->prophesize(ResponseTagger::class); + $this->cacheActivator = new CacheActivator(fn () => $this->responseTagger->reveal()); } /** @@ -42,4 +54,123 @@ public function it_must_be_activated_after_activateCaching_is_called(): void self::assertTrue($this->cacheActivator->isCachingActive()); } + + /** + * @test + */ + public function without_automatic_tagging_returns_closure_result(): void + { + $result = $this->cacheActivator->withoutAutomaticTagging(static fn () => 'my_result'); + + self::assertSame('my_result', $result); + } + + /** + * @test + */ + public function without_automatic_tagging_disables_caching_during_execution(): void + { + $activator = $this->cacheActivator; + $wasCachingActive = null; + + $this->cacheActivator->withoutAutomaticTagging(static function () use ($activator, &$wasCachingActive) { + $wasCachingActive = $activator->isCachingActive(); + }); + + self::assertFalse($wasCachingActive); + } + + /** + * @test + */ + public function without_automatic_tagging_restores_caching_state_after_execution(): void + { + $this->cacheActivator->withoutAutomaticTagging(static fn () => null); + + self::assertTrue($this->cacheActivator->isCachingActive()); + } + + /** + * @test + */ + public function without_automatic_tagging_restores_inactive_state_when_caching_was_already_disabled(): void + { + $this->cacheActivator->deactivateCaching(); + + $this->cacheActivator->withoutAutomaticTagging(static fn () => null); + + self::assertFalse($this->cacheActivator->isCachingActive()); + } + + /** + * @test + */ + public function without_automatic_tagging_restores_caching_state_even_when_closure_throws(): void + { + try { + $this->cacheActivator->withoutAutomaticTagging(static fn () => throw new \RuntimeException('error')); + } catch (\RuntimeException) { + } + + self::assertTrue($this->cacheActivator->isCachingActive()); + } + + /** + * @test + */ + public function without_automatic_tagging_tags_a_yielded_cache_tag(): void + { + $tag = CacheTag::fromString('42'); + + $this->cacheActivator->withoutAutomaticTagging(static function () use ($tag) { + yield $tag; + }); + + $this->responseTagger->tag(Argument::that( + static fn (CacheTags $tags) => $tags->toString() === $tag->toString(), + ))->shouldHaveBeenCalledOnce(); + } + + /** + * @test + */ + public function without_automatic_tagging_tags_a_yielded_cache_tags_collection(): void + { + $tags = CacheTags::fromStrings(['17', '42']); + + $this->cacheActivator->withoutAutomaticTagging(static function () use ($tags) { + yield $tags; + }); + + $this->responseTagger->tag(Argument::that( + static fn (CacheTags $t) => $t->toString() === $tags->toString(), + ))->shouldHaveBeenCalledOnce(); + } + + /** + * @test + */ + public function without_automatic_tagging_returns_generator_return_value(): void + { + $result = $this->cacheActivator->withoutAutomaticTagging(static function () { + yield CacheTag::fromString('42'); + + return 'generator_result'; + }); + + self::assertSame('generator_result', $result); + } + + /** + * @test + */ + public function without_automatic_tagging_throws_logic_exception_for_invalid_yield(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessageMatches('/Invalid yielded value at index 1 \(key: 0\)/'); + + $this->cacheActivator->withoutAutomaticTagging(static function () { + yield 'not_a_cache_tag'; + }); + } } diff --git a/tests/app/src/Controller/WithoutAutomaticTaggingController.php b/tests/app/src/Controller/WithoutAutomaticTaggingController.php new file mode 100644 index 0000000..c78da03 --- /dev/null +++ b/tests/app/src/Controller/WithoutAutomaticTaggingController.php @@ -0,0 +1,41 @@ +query->get('id'); + $shouldYield = $request->query->getBoolean('manual_tag', false); + + $asset = $this->cacheActivator->withoutAutomaticTagging(function () use ($id, $shouldYield) { + $asset = Asset::getById($id); + + if ($shouldYield && $asset) { + yield CacheTag::fromElement($asset); + } + + return $asset; + }); + + if (!$asset) { + return new Response('Asset not found', Response::HTTP_NOT_FOUND); + } + + return (new Response($asset->getData())) + ->setSharedMaxAge(3600) + ->setPublic(); + } +}