diff --git a/src/Laravel/Metadata/CachePropertyMetadataFactory.php b/src/Laravel/Metadata/CachePropertyMetadataFactory.php index f3132a55d30..d9290029784 100644 --- a/src/Laravel/Metadata/CachePropertyMetadataFactory.php +++ b/src/Laravel/Metadata/CachePropertyMetadataFactory.php @@ -17,11 +17,16 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use Illuminate\Support\Facades\Cache; -final readonly class CachePropertyMetadataFactory implements PropertyMetadataFactoryInterface +final class CachePropertyMetadataFactory implements PropertyMetadataFactoryInterface { + /** + * @var array + */ + private array $localCache = []; + public function __construct( - private PropertyMetadataFactoryInterface $decorated, - private string $cacheStore, + private readonly PropertyMetadataFactoryInterface $decorated, + private readonly string $cacheStore, ) { } @@ -29,7 +34,7 @@ public function create(string $resourceClass, string $property, array $options = { $key = hash('xxh3', serialize(['resource_class' => $resourceClass, 'property' => $property] + $options)); - return Cache::store($this->cacheStore)->rememberForever($key, function () use ($resourceClass, $property, $options) { + return $this->localCache[$key] ??= Cache::store($this->cacheStore)->rememberForever($key, function () use ($resourceClass, $property, $options) { return $this->decorated->create($resourceClass, $property, $options); }); } diff --git a/src/Laravel/Metadata/CachePropertyNameCollectionMetadataFactory.php b/src/Laravel/Metadata/CachePropertyNameCollectionMetadataFactory.php index 6269dd2080f..9b51c44151c 100644 --- a/src/Laravel/Metadata/CachePropertyNameCollectionMetadataFactory.php +++ b/src/Laravel/Metadata/CachePropertyNameCollectionMetadataFactory.php @@ -17,11 +17,16 @@ use ApiPlatform\Metadata\Property\PropertyNameCollection; use Illuminate\Support\Facades\Cache; -final readonly class CachePropertyNameCollectionMetadataFactory implements PropertyNameCollectionFactoryInterface +final class CachePropertyNameCollectionMetadataFactory implements PropertyNameCollectionFactoryInterface { + /** + * @var array + */ + private array $localCache = []; + public function __construct( - private PropertyNameCollectionFactoryInterface $decorated, - private string $cacheStore, + private readonly PropertyNameCollectionFactoryInterface $decorated, + private readonly string $cacheStore, ) { } @@ -29,7 +34,7 @@ public function create(string $resourceClass, array $options = []): PropertyName { $key = hash('xxh3', serialize(['resource_class' => $resourceClass] + $options)); - return Cache::store($this->cacheStore)->rememberForever($key, function () use ($resourceClass, $options) { + return $this->localCache[$key] ??= Cache::store($this->cacheStore)->rememberForever($key, function () use ($resourceClass, $options) { return $this->decorated->create($resourceClass, $options); }); } diff --git a/src/Laravel/Metadata/CacheResourceCollectionMetadataFactory.php b/src/Laravel/Metadata/CacheResourceCollectionMetadataFactory.php index 83836d9aae4..4da50f60a54 100644 --- a/src/Laravel/Metadata/CacheResourceCollectionMetadataFactory.php +++ b/src/Laravel/Metadata/CacheResourceCollectionMetadataFactory.php @@ -17,17 +17,22 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use Illuminate\Support\Facades\Cache; -final readonly class CacheResourceCollectionMetadataFactory implements ResourceMetadataCollectionFactoryInterface +final class CacheResourceCollectionMetadataFactory implements ResourceMetadataCollectionFactoryInterface { + /** + * @var array + */ + private array $localCache = []; + public function __construct( - private ResourceMetadataCollectionFactoryInterface $decorated, - private string $cacheStore, + private readonly ResourceMetadataCollectionFactoryInterface $decorated, + private readonly string $cacheStore, ) { } public function create(string $resourceClass): ResourceMetadataCollection { - return Cache::store($this->cacheStore)->rememberForever($resourceClass, function () use ($resourceClass) { + return $this->localCache[$resourceClass] ??= Cache::store($this->cacheStore)->rememberForever($resourceClass, function () use ($resourceClass) { return $this->decorated->create($resourceClass); }); } diff --git a/src/Laravel/Tests/Metadata/CacheMetadataFactoriesTest.php b/src/Laravel/Tests/Metadata/CacheMetadataFactoriesTest.php new file mode 100644 index 00000000000..83be893e440 --- /dev/null +++ b/src/Laravel/Tests/Metadata/CacheMetadataFactoriesTest.php @@ -0,0 +1,129 @@ + + * + * 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\Laravel\Tests\Metadata; + +use ApiPlatform\Laravel\Metadata\CachePropertyMetadataFactory; +use ApiPlatform\Laravel\Metadata\CachePropertyNameCollectionMetadataFactory; +use ApiPlatform\Laravel\Metadata\CacheResourceCollectionMetadataFactory; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use Illuminate\Support\Facades\Cache; +use Orchestra\Testbench\TestCase; + +/** + * Tests for issue #8413. + * + * Ensures the metadata cache factories serve repeated lookups from an in-memory cache + * instead of consulting the Laravel cache store on every call. Wiping the persistent + * store between two identical lookups proves the second one is answered from memory: + * without the in-memory cache, it would fall through to the (now empty) store and hit + * the decorated factory again. + */ +class CacheMetadataFactoriesTest extends TestCase +{ + public function testPropertyMetadataIsMemoizedInMemory(): void + { + $property = new ApiProperty(); + $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); + $decorated->expects($this->once()) + ->method('create') + ->with('App\Models\Book', 'title', []) + ->willReturn($property); + + $factory = new CachePropertyMetadataFactory($decorated, 'array'); + + $this->assertSame($property, $factory->create('App\Models\Book', 'title')); + + Cache::store('array')->clear(); + + $this->assertSame($property, $factory->create('App\Models\Book', 'title')); + } + + public function testPropertyNameCollectionIsMemoizedInMemory(): void + { + $collection = new PropertyNameCollection(['title']); + $decorated = $this->createMock(PropertyNameCollectionFactoryInterface::class); + $decorated->expects($this->once()) + ->method('create') + ->with('App\Models\Book', []) + ->willReturn($collection); + + $factory = new CachePropertyNameCollectionMetadataFactory($decorated, 'array'); + + $this->assertSame($collection, $factory->create('App\Models\Book')); + + Cache::store('array')->clear(); + + $this->assertSame($collection, $factory->create('App\Models\Book')); + } + + public function testResourceMetadataCollectionIsMemoizedInMemory(): void + { + $collection = new ResourceMetadataCollection('App\Models\Book'); + $decorated = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $decorated->expects($this->once()) + ->method('create') + ->with('App\Models\Book') + ->willReturn($collection); + + $factory = new CacheResourceCollectionMetadataFactory($decorated, 'array'); + + $this->assertSame($collection, $factory->create('App\Models\Book')); + + Cache::store('array')->clear(); + + $this->assertSame($collection, $factory->create('App\Models\Book')); + } + + public function testDifferentKeysAreCachedIndependently(): void + { + $title = new ApiProperty(description: 'title'); + $isbn = new ApiProperty(description: 'isbn'); + $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); + $decorated->expects($this->exactly(2)) + ->method('create') + ->willReturnCallback(static fn (string $resourceClass, string $property) => match ($property) { + 'title' => $title, + 'isbn' => $isbn, + default => throw new \LogicException(\sprintf('Unexpected property "%s".', $property)), + }); + + $factory = new CachePropertyMetadataFactory($decorated, 'array'); + + $this->assertSame($title, $factory->create('App\Models\Book', 'title')); + $this->assertSame($isbn, $factory->create('App\Models\Book', 'isbn')); + $this->assertSame($title, $factory->create('App\Models\Book', 'title')); + $this->assertSame($isbn, $factory->create('App\Models\Book', 'isbn')); + } + + public function testMetadataIsStoredInThePersistentCacheStore(): void + { + $property = new ApiProperty(); + $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); + $decorated->expects($this->once()) + ->method('create') + ->with('App\Models\Book', 'title', []) + ->willReturn($property); + + $factory = new CachePropertyMetadataFactory($decorated, 'array'); + $factory->create('App\Models\Book', 'title'); + + $key = hash('xxh3', serialize(['resource_class' => 'App\Models\Book', 'property' => 'title'])); + $this->assertSame($property, Cache::store('array')->get($key)); + } +}