From a017bda3298f4d4e19ea6d8d991a6a09c2fafc24 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 13 Nov 2026 14:59:48 +0100 Subject: [PATCH 1/5] perf(symfony): warm property info for API resources --- .../CacheWarmer/PropertyInfoCacheWarmer.php | 77 +++++++++++++++++ src/Symfony/Bundle/Resources/config/api.php | 16 +++- .../PropertyInfoCacheWarmerTest.php | 84 +++++++++++++++++++ .../ApiPlatformExtensionTest.php | 16 ++++ 4 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Bundle/CacheWarmer/PropertyInfoCacheWarmer.php create mode 100644 src/Symfony/Tests/Bundle/CacheWarmer/PropertyInfoCacheWarmerTest.php diff --git a/src/Symfony/Bundle/CacheWarmer/PropertyInfoCacheWarmer.php b/src/Symfony/Bundle/CacheWarmer/PropertyInfoCacheWarmer.php new file mode 100644 index 00000000000..045d2701e5f --- /dev/null +++ b/src/Symfony/Bundle/CacheWarmer/PropertyInfoCacheWarmer.php @@ -0,0 +1,77 @@ + + * + * 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\Bundle\CacheWarmer; + +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\NullAdapter; +use Symfony\Component\Cache\Adapter\PhpArrayAdapter; +use Symfony\Component\Config\Resource\ClassExistenceResource; +use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; +use Symfony\Component\PropertyInfo\PropertyInfoCacheExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; + +final class PropertyInfoCacheWarmer implements CacheWarmerInterface +{ + public function __construct( + private readonly PropertyInfoExtractorInterface $extractor, + private readonly ResourceNameCollectionFactoryInterface $resourceNames, + private readonly string $file, + ) { + } + + public function isOptional(): bool + { + return true; + } + + public function warmUp(string $cacheDir, ?string $buildDir = null): array + { + if (null === $buildDir) { + return []; + } + + $pool = new ArrayAdapter(0, false); + $extractor = new PropertyInfoCacheExtractor($this->extractor, $pool); + + spl_autoload_register([ClassExistenceResource::class, 'throwOnRequiredClass']); + try { + foreach ($this->resourceNames->create() as $class) { + try { + $properties = array_unique(array_merge( + $extractor->getProperties($class) ?? [], + $extractor->getProperties($class, ['serializer_groups' => null]) ?? [], + )); + + foreach ($properties as $property) { + $extractor->getType($class, $property); + $extractor->getShortDescription($class, $property); + $extractor->isReadable($class, $property); + $extractor->isWritable($class, $property); + $extractor->isInitializable($class, $property); + } + } catch (\Exception $e) { + try { + ClassExistenceResource::throwOnRequiredClass($class, $e); + } catch (\ReflectionException) { + } + } + } + } finally { + spl_autoload_unregister([ClassExistenceResource::class, 'throwOnRequiredClass']); + } + + return (new PhpArrayAdapter($this->file, new NullAdapter()))->warmUp($pool->getValues()); + } +} diff --git a/src/Symfony/Bundle/Resources/config/api.php b/src/Symfony/Bundle/Resources/config/api.php index 91f8ce1d406..08527ea6f03 100644 --- a/src/Symfony/Bundle/Resources/config/api.php +++ b/src/Symfony/Bundle/Resources/config/api.php @@ -42,6 +42,7 @@ use ApiPlatform\State\SerializerContextBuilderInterface; use ApiPlatform\Symfony\Action\NotExposedAction; use ApiPlatform\Symfony\Action\NotFoundAction; +use ApiPlatform\Symfony\Bundle\CacheWarmer\PropertyInfoCacheWarmer; use ApiPlatform\Symfony\Routing\ApiLoader; use ApiPlatform\Symfony\Routing\IriConverter; use ApiPlatform\Symfony\Routing\Router; @@ -50,6 +51,7 @@ use phpDocumentor\Reflection\DocBlockFactory; use phpDocumentor\Reflection\Types\ContextFactory; use PHPStan\PhpDocParser\Parser\PhpDocParser; +use Symfony\Component\Cache\Adapter\PhpArrayAdapter; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; @@ -107,9 +109,21 @@ ->decorate('api_platform.property_info') ->args([ service('.inner'), - service('cache.property_info'), + service('api_platform.property_info.cache.adapter'), ]); + $services->set('api_platform.property_info.cache.adapter', PhpArrayAdapter::class) + ->factory([PhpArrayAdapter::class, 'create']) + ->args(['%kernel.build_dir%/api_platform_property_info.php', service('cache.property_info')]); + + $services->set('api_platform.property_info.cache_warmer', PropertyInfoCacheWarmer::class) + ->args([ + service('api_platform.property_info.cache.inner'), + service('api_platform.metadata.resource.name_collection_factory'), + '%kernel.build_dir%/api_platform_property_info.php', + ]) + ->tag('kernel.cache_warmer'); + $services->set('api_platform.negotiator', Negotiator::class); $services->set('api_platform.resource_class_resolver', ResourceClassResolver::class) diff --git a/src/Symfony/Tests/Bundle/CacheWarmer/PropertyInfoCacheWarmerTest.php b/src/Symfony/Tests/Bundle/CacheWarmer/PropertyInfoCacheWarmerTest.php new file mode 100644 index 00000000000..00c273961ed --- /dev/null +++ b/src/Symfony/Tests/Bundle/CacheWarmer/PropertyInfoCacheWarmerTest.php @@ -0,0 +1,84 @@ + + * + * 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\Bundle\CacheWarmer; + +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Symfony\Bundle\CacheWarmer\PropertyInfoCacheWarmer; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\PhpArrayAdapter; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoCacheExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; + +final class PropertyInfoCacheWarmerTest extends TestCase +{ + private string $cacheDir; + + protected function setUp(): void + { + $this->cacheDir = sys_get_temp_dir().'/api_platform_property_info_warmer_'.bin2hex(random_bytes(4)); + } + + protected function tearDown(): void + { + (new Filesystem())->remove($this->cacheDir); + } + + public function testWarmsResourcePropertiesAndKeepsTheFallback(): void + { + $reflection = new ReflectionExtractor(); + $extractor = new PropertyInfoExtractor([$reflection], [$reflection], [], [$reflection], [$reflection]); + $resourceNames = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $resourceNames->expects($this->once())->method('create')->willReturn(new ResourceNameCollection([PropertyInfoWarmupSubject::class])); + $file = $this->cacheDir.'/build/property_info.php'; + + $warmer = new PropertyInfoCacheWarmer($extractor, $resourceNames, $file); + $warmer->warmUp($this->cacheDir, $this->cacheDir.'/build'); + + $this->assertFileExists($file); + + $fallback = new ArrayAdapter(); + $cached = new PropertyInfoCacheExtractor($extractor, PhpArrayAdapter::create($file, $fallback)); + + $this->assertContains('name', $cached->getProperties(PropertyInfoWarmupSubject::class)); + $this->assertContains('name', $cached->getProperties(PropertyInfoWarmupSubject::class, ['serializer_groups' => null])); + $this->assertEquals($extractor->getType(PropertyInfoWarmupSubject::class, 'name'), $cached->getType(PropertyInfoWarmupSubject::class, 'name')); + $this->assertNull($cached->getShortDescription(PropertyInfoWarmupSubject::class, 'name')); + $this->assertSame($extractor->isReadable(PropertyInfoWarmupSubject::class, 'name'), $cached->isReadable(PropertyInfoWarmupSubject::class, 'name')); + $this->assertSame($extractor->isWritable(PropertyInfoWarmupSubject::class, 'name'), $cached->isWritable(PropertyInfoWarmupSubject::class, 'name')); + $this->assertSame($extractor->isInitializable(PropertyInfoWarmupSubject::class, 'name'), $cached->isInitializable(PropertyInfoWarmupSubject::class, 'name')); + + foreach (['getType', 'getShortDescription', 'isReadable', 'isWritable', 'isInitializable'] as $method) { + $key = rawurlencode($method.'.'.serialize([PropertyInfoWarmupSubject::class, 'name', []])); + $this->assertFalse($fallback->hasItem($key), $method.' must use the warmed cache.'); + } + + $cached->getShortDescription(PropertyInfoUnknownSubject::class, 'name'); + $key = rawurlencode('getShortDescription.'.serialize([PropertyInfoUnknownSubject::class, 'name', []])); + $this->assertTrue($fallback->hasItem($key)); + } +} + +final class PropertyInfoWarmupSubject +{ + public string $name; +} + +final class PropertyInfoUnknownSubject +{ + public string $name; +} diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 8f80ae8e788..bfe52eaab68 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -25,6 +25,7 @@ use ApiPlatform\State\Pagination\PaginationOptions; use ApiPlatform\State\SerializerContextBuilderInterface; use ApiPlatform\Symfony\Action\NotFoundAction; +use ApiPlatform\Symfony\Bundle\CacheWarmer\PropertyInfoCacheWarmer; use ApiPlatform\Symfony\Bundle\DependencyInjection\ApiPlatformExtension; use ApiPlatform\Tests\Fixtures\TestBundle\TestBundle; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; @@ -36,6 +37,7 @@ use Symfony\Bundle\FrameworkBundle\Command\TranslationExtractCommand as FrameworkTranslationExtractCommand; use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Cache\Adapter\PhpArrayAdapter; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; @@ -590,6 +592,20 @@ public function testCachePoolClearerClearsPropertyInfoAndOperationMetadataCaches $this->assertContains('api_platform.cache.metadata.operation', $pools); } + public function testPropertyInfoCacheIsWarmed(): void + { + (new ApiPlatformExtension())->load(self::DEFAULT_CONFIG, $this->container); + + $adapter = $this->container->getDefinition('api_platform.property_info.cache.adapter'); + $this->assertSame(PhpArrayAdapter::class, $adapter->getClass()); + $this->assertSame('cache.property_info', (string) $adapter->getArgument(1)); + $this->assertSame('api_platform.property_info.cache.adapter', (string) $this->container->getDefinition('api_platform.property_info.cache')->getArgument(1)); + + $warmer = $this->container->getDefinition('api_platform.property_info.cache_warmer'); + $this->assertSame(PropertyInfoCacheWarmer::class, $warmer->getClass()); + $this->assertArrayHasKey('kernel.cache_warmer', $warmer->getTags()); + } + /** * @see https://github.com/api-platform/core/issues/8095 */ From 00a0f1f4e5152e02b5b5bc5a47222826a7efdbc2 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 13 Nov 2026 15:39:13 +0100 Subject: [PATCH 2/5] perf(symfony): register API resources for Symfony property info warmup --- src/Symfony/Bundle/Resources/config/api.php | 5 ++++ .../PropertyInfoCacheWarmerTest.php | 28 +++++++++++++++++++ .../ApiPlatformExtensionTest.php | 6 ++++ 3 files changed, 39 insertions(+) diff --git a/src/Symfony/Bundle/Resources/config/api.php b/src/Symfony/Bundle/Resources/config/api.php index 08527ea6f03..df146d70c22 100644 --- a/src/Symfony/Bundle/Resources/config/api.php +++ b/src/Symfony/Bundle/Resources/config/api.php @@ -18,6 +18,7 @@ use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation\DashPathSegmentNameGenerator; use ApiPlatform\Metadata\Operation\UnderscorePathSegmentNameGenerator; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; use ApiPlatform\Metadata\ResourceClassResolver; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UriVariablesConverter; @@ -124,6 +125,10 @@ ]) ->tag('kernel.cache_warmer'); + $services->set('api_platform.property_info.symfony_warmup_classes', ResourceNameCollection::class) + ->factory([service('api_platform.metadata.resource.name_collection_factory'), 'create']) + ->tag('property_info.warmup_class_provider'); + $services->set('api_platform.negotiator', Negotiator::class); $services->set('api_platform.resource_class_resolver', ResourceClassResolver::class) diff --git a/src/Symfony/Tests/Bundle/CacheWarmer/PropertyInfoCacheWarmerTest.php b/src/Symfony/Tests/Bundle/CacheWarmer/PropertyInfoCacheWarmerTest.php index 00c273961ed..058f50ba210 100644 --- a/src/Symfony/Tests/Bundle/CacheWarmer/PropertyInfoCacheWarmerTest.php +++ b/src/Symfony/Tests/Bundle/CacheWarmer/PropertyInfoCacheWarmerTest.php @@ -71,6 +71,34 @@ public function testWarmsResourcePropertiesAndKeepsTheFallback(): void $key = rawurlencode('getShortDescription.'.serialize([PropertyInfoUnknownSubject::class, 'name', []])); $this->assertTrue($fallback->hasItem($key)); } + + public function testSkipsWarmupWithoutBuildDirectory(): void + { + $resourceNames = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $resourceNames->expects($this->never())->method('create'); + $file = $this->cacheDir.'/build/property_info.php'; + $warmer = new PropertyInfoCacheWarmer(new PropertyInfoExtractor(), $resourceNames, $file); + + $this->assertTrue($warmer->isOptional()); + $this->assertSame([], $warmer->warmUp($this->cacheDir)); + $this->assertFileDoesNotExist($file); + } + + public function testMissingResourceDoesNotStopWarmup(): void + { + $reflection = new ReflectionExtractor(); + $extractor = new PropertyInfoExtractor([$reflection], [$reflection], [], [$reflection], [$reflection]); + $resourceNames = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $resourceNames->expects($this->once())->method('create')->willReturn(new ResourceNameCollection([__NAMESPACE__.'\\PropertyInfoMissingSubject', PropertyInfoWarmupSubject::class])); + $file = $this->cacheDir.'/build/property_info.php'; + + (new PropertyInfoCacheWarmer($extractor, $resourceNames, $file))->warmUp($this->cacheDir, $this->cacheDir.'/build'); + + $fallback = new ArrayAdapter(); + $cached = new PropertyInfoCacheExtractor($extractor, PhpArrayAdapter::create($file, $fallback)); + $this->assertContains('name', $cached->getProperties(PropertyInfoWarmupSubject::class)); + $this->assertSame([], $fallback->getValues()); + } } final class PropertyInfoWarmupSubject diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index bfe52eaab68..8b0c9dfdff2 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Serializer\Filter\GroupFilter; @@ -604,6 +605,11 @@ public function testPropertyInfoCacheIsWarmed(): void $warmer = $this->container->getDefinition('api_platform.property_info.cache_warmer'); $this->assertSame(PropertyInfoCacheWarmer::class, $warmer->getClass()); $this->assertArrayHasKey('kernel.cache_warmer', $warmer->getTags()); + + $provider = $this->container->getDefinition('api_platform.property_info.symfony_warmup_classes'); + $this->assertSame(ResourceNameCollection::class, $provider->getClass()); + $this->assertSame('api_platform.metadata.resource.name_collection_factory', (string) $provider->getFactory()[0]); + $this->assertArrayHasKey('property_info.warmup_class_provider', $provider->getTags()); } /** From 0d52fcbf98097ac8b2797a12ba094ace37675a43 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 13 Nov 2026 15:50:46 +0100 Subject: [PATCH 3/5] ci: upload component coverage reports --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72abaf9055d..bfc935493c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -433,7 +433,7 @@ jobs: path: /tmp/build/logs/phpunit continue-on-error: true - name: Upload coverage results to Codecov - if: matrix.coverage + if: matrix.php.coverage uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: token: ${{ secrets.CODECOV_TOKEN }} From 8f835eedad12cb0baa7a0baa32429bdb7300ddbe Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 13 Nov 2026 16:26:49 +0100 Subject: [PATCH 4/5] perf(symfony): provide resource classes to metadata warmers --- src/Symfony/Bundle/Resources/config/api.php | 8 +++++--- .../DependencyInjection/ApiPlatformExtensionTest.php | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Symfony/Bundle/Resources/config/api.php b/src/Symfony/Bundle/Resources/config/api.php index df146d70c22..baccee6a042 100644 --- a/src/Symfony/Bundle/Resources/config/api.php +++ b/src/Symfony/Bundle/Resources/config/api.php @@ -123,11 +123,13 @@ service('api_platform.metadata.resource.name_collection_factory'), '%kernel.build_dir%/api_platform_property_info.php', ]) - ->tag('kernel.cache_warmer'); + ->tag('kernel.cache_warmer', ['priority' => 75]); - $services->set('api_platform.property_info.symfony_warmup_classes', ResourceNameCollection::class) + $services->set('api_platform.metadata.symfony_warmup_classes', ResourceNameCollection::class) ->factory([service('api_platform.metadata.resource.name_collection_factory'), 'create']) - ->tag('property_info.warmup_class_provider'); + ->tag('property_info.warmup_class_provider') + ->tag('serializer.warmup_class_provider') + ->tag('validator.warmup_class_provider'); $services->set('api_platform.negotiator', Negotiator::class); diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 8b0c9dfdff2..bd92f9f5dff 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -604,12 +604,14 @@ public function testPropertyInfoCacheIsWarmed(): void $warmer = $this->container->getDefinition('api_platform.property_info.cache_warmer'); $this->assertSame(PropertyInfoCacheWarmer::class, $warmer->getClass()); - $this->assertArrayHasKey('kernel.cache_warmer', $warmer->getTags()); + $this->assertSame([['priority' => 75]], $warmer->getTag('kernel.cache_warmer')); - $provider = $this->container->getDefinition('api_platform.property_info.symfony_warmup_classes'); + $provider = $this->container->getDefinition('api_platform.metadata.symfony_warmup_classes'); $this->assertSame(ResourceNameCollection::class, $provider->getClass()); $this->assertSame('api_platform.metadata.resource.name_collection_factory', (string) $provider->getFactory()[0]); $this->assertArrayHasKey('property_info.warmup_class_provider', $provider->getTags()); + $this->assertArrayHasKey('serializer.warmup_class_provider', $provider->getTags()); + $this->assertArrayHasKey('validator.warmup_class_provider', $provider->getTags()); } /** From a14097d87b500e2b6146ad4604252745667e4da0 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 13 Nov 2026 16:52:58 +0100 Subject: [PATCH 5/5] perf(symfony): warm property metadata in a PHP array --- .../PropertyMetadataCacheWarmer.php | 78 ++++++++++++++ .../Resources/config/metadata/property.php | 18 +++- .../config/metadata/property_name.php | 2 +- .../PropertyMetadataCacheWarmerTest.php | 102 ++++++++++++++++++ .../ApiPlatformExtensionTest.php | 25 +++++ 5 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 src/Symfony/Bundle/CacheWarmer/PropertyMetadataCacheWarmer.php create mode 100644 src/Symfony/Tests/Bundle/CacheWarmer/PropertyMetadataCacheWarmerTest.php diff --git a/src/Symfony/Bundle/CacheWarmer/PropertyMetadataCacheWarmer.php b/src/Symfony/Bundle/CacheWarmer/PropertyMetadataCacheWarmer.php new file mode 100644 index 00000000000..171b71f513e --- /dev/null +++ b/src/Symfony/Bundle/CacheWarmer/PropertyMetadataCacheWarmer.php @@ -0,0 +1,78 @@ + + * + * 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\Bundle\CacheWarmer; + +use ApiPlatform\Metadata\Property\Factory\CachedPropertyMetadataFactory; +use ApiPlatform\Metadata\Property\Factory\CachedPropertyNameCollectionFactory; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\NullAdapter; +use Symfony\Component\Cache\Adapter\PhpArrayAdapter; +use Symfony\Component\Config\Resource\ClassExistenceResource; +use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; + +final class PropertyMetadataCacheWarmer implements CacheWarmerInterface +{ + public function __construct( + private readonly ResourceNameCollectionFactoryInterface $resources, + private readonly PropertyNameCollectionFactoryInterface $names, + private readonly PropertyMetadataFactoryInterface $metadata, + private readonly CacheItemPoolInterface $fallbackPool, + private readonly string $file, + ) { + } + + public function isOptional(): bool + { + return true; + } + + public function warmUp(string $cacheDir, ?string $buildDir = null): array + { + if (null === $buildDir) { + return []; + } + + $pool = new ArrayAdapter(0, false); + $names = new CachedPropertyNameCollectionFactory($pool, $this->names); + $metadata = new CachedPropertyMetadataFactory($pool, $this->metadata); + + spl_autoload_register([ClassExistenceResource::class, 'throwOnRequiredClass']); + try { + foreach ($this->resources->create() as $class) { + try { + foreach ($names->create($class) as $property) { + $metadata->create($class, $property); + } + } catch (\Exception $e) { + try { + ClassExistenceResource::throwOnRequiredClass($class, $e); + } catch (\ReflectionException) { + } + } + } + } finally { + spl_autoload_unregister([ClassExistenceResource::class, 'throwOnRequiredClass']); + } + + $values = $pool->getValues(); + $preload = (new PhpArrayAdapter($this->file, new NullAdapter()))->warmUp($values); + $this->fallbackPool->deleteItems(array_keys($values)); + + return $preload; + } +} diff --git a/src/Symfony/Bundle/Resources/config/metadata/property.php b/src/Symfony/Bundle/Resources/config/metadata/property.php index bfcc0f359d7..9308feac7d6 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/property.php +++ b/src/Symfony/Bundle/Resources/config/metadata/property.php @@ -21,6 +21,8 @@ use ApiPlatform\Metadata\Property\Factory\PropertyInfoPropertyMetadataFactory; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\SerializerPropertyMetadataFactory; +use ApiPlatform\Symfony\Bundle\CacheWarmer\PropertyMetadataCacheWarmer; +use Symfony\Component\Cache\Adapter\PhpArrayAdapter; return function (ContainerConfigurator $container) { $services = $container->services(); @@ -51,7 +53,7 @@ $services->set('api_platform.metadata.property.metadata_factory.cached', CachedPropertyMetadataFactory::class) ->decorate('api_platform.metadata.property.metadata_factory', null, -10) ->args([ - service('api_platform.cache.metadata.property'), + service('api_platform.cache.metadata.property.php_array'), service('api_platform.metadata.property.metadata_factory.cached.inner'), ]); @@ -69,6 +71,20 @@ $services->set('api_platform.metadata.property.metadata_factory.xml', ExtractorPropertyMetadataFactory::class) ->args([service('api_platform.metadata.property_extractor.xml')]); + $services->set('api_platform.metadata.property.cache_warmer', PropertyMetadataCacheWarmer::class) + ->args([ + service('api_platform.metadata.resource.name_collection_factory'), + service('api_platform.metadata.property.name_collection_factory'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.cache.metadata.property'), + '%kernel.build_dir%/api_platform_property_metadata.php', + ]) + ->tag('kernel.cache_warmer', ['priority' => -10]); + + $services->set('api_platform.cache.metadata.property.php_array', PhpArrayAdapter::class) + ->factory([PhpArrayAdapter::class, 'create']) + ->args(['%kernel.build_dir%/api_platform_property_metadata.php', service('api_platform.cache.metadata.property')]); + $services->set('api_platform.cache.metadata.property') ->parent('cache.system') ->tag('cache.pool'); diff --git a/src/Symfony/Bundle/Resources/config/metadata/property_name.php b/src/Symfony/Bundle/Resources/config/metadata/property_name.php index 75a3962ef43..273e18f3834 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/property_name.php +++ b/src/Symfony/Bundle/Resources/config/metadata/property_name.php @@ -32,7 +32,7 @@ $services->set('api_platform.metadata.property.name_collection_factory.cached', CachedPropertyNameCollectionFactory::class) ->decorate('api_platform.metadata.property.name_collection_factory', null, -10) ->args([ - service('api_platform.cache.metadata.property'), + service('api_platform.cache.metadata.property.php_array'), service('api_platform.metadata.property.name_collection_factory.cached.inner'), ]); diff --git a/src/Symfony/Tests/Bundle/CacheWarmer/PropertyMetadataCacheWarmerTest.php b/src/Symfony/Tests/Bundle/CacheWarmer/PropertyMetadataCacheWarmerTest.php new file mode 100644 index 00000000000..e9c59fcb97c --- /dev/null +++ b/src/Symfony/Tests/Bundle/CacheWarmer/PropertyMetadataCacheWarmerTest.php @@ -0,0 +1,102 @@ + + * + * 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\Bundle\CacheWarmer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Property\Factory\CachedPropertyMetadataFactory; +use ApiPlatform\Metadata\Property\Factory\CachedPropertyNameCollectionFactory; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Symfony\Bundle\CacheWarmer\PropertyMetadataCacheWarmer; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\PhpArrayAdapter; +use Symfony\Component\Filesystem\Filesystem; + +final class PropertyMetadataCacheWarmerTest extends TestCase +{ + private string $cacheDir; + + protected function setUp(): void + { + $this->cacheDir = sys_get_temp_dir().'/api_platform_property_metadata_warmer_'.bin2hex(random_bytes(4)); + } + + protected function tearDown(): void + { + (new Filesystem())->remove($this->cacheDir); + } + + public function testWarmsPropertyMetadataAndRemovesFallbackCopies(): void + { + $class = PropertyMetadataWarmupSubject::class; + $nameKey = 'property_name_collection_'.hash('xxh3', serialize([$class, []])); + $metadataKey = 'property_metadata_'.hash('xxh3', serialize([$class, 'name', []])); + $fallback = new ArrayAdapter(); + $fallback->save($fallback->getItem($nameKey)->set(new PropertyNameCollection(['name']))); + $fallback->save($fallback->getItem($metadataKey)->set(new ApiProperty(description: 'warmed'))); + $fallback->save($fallback->getItem('other')->set('keep')); + + $resources = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $resources->expects($this->once())->method('create')->willReturn(new ResourceNameCollection([$class])); + $names = $this->createMock(PropertyNameCollectionFactoryInterface::class); + $names->expects($this->once())->method('create')->with($class, [])->willReturn(new PropertyNameCollection(['name'])); + $metadata = $this->createMock(PropertyMetadataFactoryInterface::class); + $metadata->expects($this->once())->method('create')->with($class, 'name', [])->willReturn(new ApiProperty(description: 'warmed')); + $file = $this->cacheDir.'/build/property_metadata.php'; + + $warmer = new PropertyMetadataCacheWarmer($resources, $names, $metadata, $fallback, $file); + $warmer->warmUp($this->cacheDir, $this->cacheDir.'/build'); + + $cached = PhpArrayAdapter::create($file, new ArrayAdapter()); + $this->assertFileExists($file); + $this->assertSame(['name'], iterator_to_array($cached->getItem($nameKey)->get())); + $this->assertSame('warmed', $cached->getItem($metadataKey)->get()->getDescription()); + $this->assertSame(['name'], iterator_to_array((new CachedPropertyNameCollectionFactory($cached, $names))->create($class))); + $this->assertSame('warmed', (new CachedPropertyMetadataFactory($cached, $metadata))->create($class, 'name')->getDescription()); + $this->assertFalse($fallback->hasItem($nameKey)); + $this->assertFalse($fallback->hasItem($metadataKey)); + $this->assertSame('keep', $fallback->getItem('other')->get()); + } + + public function testSkipsWarmupWithoutBuildDirectory(): void + { + $resources = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $resources->expects($this->never())->method('create'); + $names = $this->createMock(PropertyNameCollectionFactoryInterface::class); + $names->expects($this->never())->method('create'); + $metadata = $this->createMock(PropertyMetadataFactoryInterface::class); + $metadata->expects($this->never())->method('create'); + $file = $this->cacheDir.'/build/property_metadata.php'; + $warmer = new PropertyMetadataCacheWarmer( + $resources, + $names, + $metadata, + new ArrayAdapter(), + $file, + ); + + $this->assertTrue($warmer->isOptional()); + $this->assertSame([], $warmer->warmUp($this->cacheDir)); + $this->assertFileDoesNotExist($file); + } +} + +final class PropertyMetadataWarmupSubject +{ + public string $name; +} diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index bd92f9f5dff..ffdcf02261e 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -27,6 +27,7 @@ use ApiPlatform\State\SerializerContextBuilderInterface; use ApiPlatform\Symfony\Action\NotFoundAction; use ApiPlatform\Symfony\Bundle\CacheWarmer\PropertyInfoCacheWarmer; +use ApiPlatform\Symfony\Bundle\CacheWarmer\PropertyMetadataCacheWarmer; use ApiPlatform\Symfony\Bundle\DependencyInjection\ApiPlatformExtension; use ApiPlatform\Tests\Fixtures\TestBundle\TestBundle; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; @@ -614,6 +615,30 @@ public function testPropertyInfoCacheIsWarmed(): void $this->assertArrayHasKey('validator.warmup_class_provider', $provider->getTags()); } + public function testPropertyMetadataCacheIsWarmed(): void + { + (new ApiPlatformExtension())->load(self::DEFAULT_CONFIG, $this->container); + + $fallback = $this->container->getDefinition('api_platform.cache.metadata.property'); + $this->assertInstanceOf(ChildDefinition::class, $fallback); + $this->assertSame('cache.system', $fallback->getParent()); + $this->assertTrue($fallback->hasTag('cache.pool')); + + $pool = $this->container->getDefinition('api_platform.cache.metadata.property.php_array'); + $this->assertSame(PhpArrayAdapter::class, $pool->getClass()); + $this->assertSame([PhpArrayAdapter::class, 'create'], $pool->getFactory()); + $this->assertSame('%kernel.build_dir%/api_platform_property_metadata.php', $pool->getArgument(0)); + $this->assertSame('api_platform.cache.metadata.property', (string) $pool->getArgument(1)); + $this->assertFalse($pool->hasTag('cache.pool')); + $this->assertSame('api_platform.cache.metadata.property.php_array', (string) $this->container->getDefinition('api_platform.metadata.property.name_collection_factory.cached')->getArgument(0)); + $this->assertSame('api_platform.cache.metadata.property.php_array', (string) $this->container->getDefinition('api_platform.metadata.property.metadata_factory.cached')->getArgument(0)); + + $warmer = $this->container->getDefinition('api_platform.metadata.property.cache_warmer'); + $this->assertSame(PropertyMetadataCacheWarmer::class, $warmer->getClass()); + $this->assertSame([['priority' => -10]], $warmer->getTag('kernel.cache_warmer')); + $this->assertSame('api_platform.cache.metadata.property', (string) $warmer->getArgument(3)); + } + /** * @see https://github.com/api-platform/core/issues/8095 */