diff --git a/Classes/Domain/Repository/ApiResourceRepository.php b/Classes/Domain/Repository/ApiResourceRepository.php index 40ec8ec2..0ff4e34f 100644 --- a/Classes/Domain/Repository/ApiResourceRepository.php +++ b/Classes/Domain/Repository/ApiResourceRepository.php @@ -17,6 +17,14 @@ class ApiResourceRepository { protected ?FrontendInterface $cache; + /** + * API resources already read in this process, by cache identifier. `getByEntity()` runs for every serialized + * entity, so reading and unserializing the cache entry on each call would make large collections slow. + * + * @var array + */ + protected array $apiResourcesByCacheIdentifier = []; + public function __construct( protected readonly CacheManager $cacheManager, protected readonly ReflectionService $reflectionService, @@ -32,6 +40,14 @@ public function getAll(): array { $cacheIdentifier = $this->buildCacheIdentifier(); + return $this->apiResourcesByCacheIdentifier[$cacheIdentifier] ??= $this->loadAll($cacheIdentifier); + } + + /** + * @return ApiResource[] + */ + protected function loadAll(string $cacheIdentifier): array + { $apiResources = $this->cache->get($cacheIdentifier); if ($apiResources !== false) { diff --git a/Tests/Functional/Dispatcher/ApiResourceCacheDispatcherTest.php b/Tests/Functional/Dispatcher/ApiResourceCacheDispatcherTest.php new file mode 100644 index 00000000..df147e19 --- /dev/null +++ b/Tests/Functional/Dispatcher/ApiResourceCacheDispatcherTest.php @@ -0,0 +1,51 @@ + [ + 'caching' => [ + 'cacheConfigurations' => [ + 't3api' => [ + 'frontend' => VariableFrontend::class, + 'backend' => ReadCountingCacheBackend::class, + 'groups' => ['system'], + ], + ], + ], + ], + ]; + + protected function setUp(): void + { + parent::setUp(); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/products.csv'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/categories.csv'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/tags.csv'); + } + + #[Test] + public function collectionWithRelatedResourcesReadsApiResourcesFromCacheOnce(): void + { + $this->dispatchGet('https://example.com/_api/products'); + + self::assertSame(1, $this->getT3apiCacheBackend()->readCount); + } + + private function getT3apiCacheBackend(): ReadCountingCacheBackend + { + $backend = $this->get(CacheManager::class)->getCache('t3api')->getBackend(); + self::assertInstanceOf(ReadCountingCacheBackend::class, $backend); + + return $backend; + } +} diff --git a/Tests/Functional/Fixtures/ReadCountingCacheBackend.php b/Tests/Functional/Fixtures/ReadCountingCacheBackend.php new file mode 100644 index 00000000..810d0f1e --- /dev/null +++ b/Tests/Functional/Fixtures/ReadCountingCacheBackend.php @@ -0,0 +1,25 @@ +readCount++; + + return parent::get($entryIdentifier); + } +} diff --git a/Tests/Unit/Domain/Repository/ApiResourceRepositoryTest.php b/Tests/Unit/Domain/Repository/ApiResourceRepositoryTest.php new file mode 100644 index 00000000..11a1bb76 --- /dev/null +++ b/Tests/Unit/Domain/Repository/ApiResourceRepositoryTest.php @@ -0,0 +1,105 @@ +site = new Site('test', 1, [ + 'base' => 'https://example.com/', + 'languages' => [ + ['languageId' => 0, 'title' => 'English', 'base' => '/', 'locale' => 'en_US.UTF-8'], + ['languageId' => 1, 'title' => 'German', 'base' => '/de/', 'locale' => 'de_DE.UTF-8'], + ], + 'routeEnhancers' => [ + 'T3api' => ['type' => ResourceEnhancer::ENHANCER_NAME, 'basePath' => '_api'], + ], + ]); + } + + protected function tearDown(): void + { + unset($GLOBALS['TYPO3_REQUEST']); + SiteService::reset(); + parent::tearDown(); + } + + #[Test] + public function getByEntityReadsCacheOnlyOnceForManyEntities(): void + { + $this->setCurrentRequestLanguage(0); + $apiResourceRepository = $this->createApiResourceRepository(); + + $apiResourceRepository->getByEntity(\stdClass::class); + $apiResourceRepository->getByEntity(\stdClass::class); + $apiResourceRepository->getByEntity(\stdClass::class); + + self::assertSame(['ApiResourceRepository_getAll_api'], $this->readCacheIdentifiers); + } + + #[Test] + public function getAllReadsCacheOncePerApiBasePath(): void + { + $apiResourceRepository = $this->createApiResourceRepository(); + + foreach ([0, 1, 0, 1] as $languageId) { + $this->setCurrentRequestLanguage($languageId); + $apiResourceRepository->getAll(); + } + + self::assertSame( + ['ApiResourceRepository_getAll_api', 'ApiResourceRepository_getAllde_api'], + $this->readCacheIdentifiers + ); + } + + private function createApiResourceRepository(): ApiResourceRepository + { + $cache = self::createStub(FrontendInterface::class); + $cache->method('get')->willReturnCallback(function (string $cacheIdentifier): array { + $this->readCacheIdentifiers[] = $cacheIdentifier; + + return []; + }); + $cacheManager = self::createStub(CacheManager::class); + $cacheManager->method('getCache')->willReturnMap([['t3api', $cache]]); + + return new ApiResourceRepository( + $cacheManager, + self::createStub(ReflectionService::class), + self::createStub(ApiResourceFactory::class) + ); + } + + private function setCurrentRequestLanguage(int $languageId): void + { + $language = $this->site->getLanguageById($languageId); + $GLOBALS['TYPO3_REQUEST'] = (new ServerRequest($language->getBase() . '_api/news/news', 'GET')) + ->withAttribute('site', $this->site) + ->withAttribute('language', $language); + } +}