diff --git a/README.md b/README.md index f6bdb59..9cd351e 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,37 @@ $filesystem = new MultiFilesystem( $filesystem->file('another/file.txt'); // File from "filesystem2" ``` +### `CacheFilesystem` + +> [!NOTE] +> A `psr/cache-implementation` is required. + +```php +use Zenstruck\Filesystem\CacheFilesystem; +use Zenstruck\Filesystem\Node\Mapping; + +/** @var \Zenstruck\Filesystem $inner */ +/** @var \Psr\Cache\CacheItemPoolInterface $cache */ + +$filesystem = new CacheFilesystem( + inner: $inner, + cache: $cache, + metadata: [ // array of metadata to cache (see Zenstruck\Filesystem\Node\Mapping) + Mapping::LAST_MODIFIED, + Mapping::SIZE, + ], + ttl: 3600, // or null for no TTL +); + +$filesystem->write('file.txt', 'content'); // caches metadata + +$file = $filesystem->file('file.txt'); +$file->lastModified(); // cached value +$file->size(); // cached value +$file->checksum(); // real value (as this wasn't configured to be cached) +$file->contents(); // actually reads the file (contents cannot be cached) +``` + ### `LoggableFilesystem` > [!NOTE] @@ -645,7 +676,7 @@ class MyTest extends TestCase implements FilesystemProvider $filesystem->assertExists('file.txt'); } - public function createFilesystem(): Filesystem|FilesystemAdapter|string; + public function createFilesystem(): Filesystem|FilesystemAdapter|string { return '/some/temp/dir'; } @@ -925,6 +956,24 @@ zenstruck_filesystem: # Default expiry expires: null # Example: '+ 30 minutes' + # Cache file/image metadata + cache: + enabled: false + + # PSR-6 cache pool service id + pool: cache.app + + # Cache TTL (null for no TTL) + ttl: null + + # File/image metadata to cache (see Zenstruck\Filesystem\Node\Mapping) + metadata: ~ + + # Examples: + # - last_modified + # - size + # - dimensions + # Dispatch filesystem operation events events: enabled: false diff --git a/src/Filesystem/CacheFilesystem.php b/src/Filesystem/CacheFilesystem.php new file mode 100644 index 0000000..1f7b64a --- /dev/null +++ b/src/Filesystem/CacheFilesystem.php @@ -0,0 +1,182 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Filesystem; + +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Contracts\Cache\ItemInterface; +use Symfony\Contracts\Cache\TagAwareCacheInterface; +use Zenstruck\Filesystem; +use Zenstruck\Filesystem\Node\Directory; +use Zenstruck\Filesystem\Node\File; +use Zenstruck\Filesystem\Node\File\Image; +use Zenstruck\Filesystem\Node\File\Image\LazyImage; +use Zenstruck\Filesystem\Node\File\LazyFile; +use Zenstruck\Filesystem\Node\Mapping; + +/** + * @author Kevin Bond + * + * @phpstan-import-type Format from Mapping + * @phpstan-import-type Serialized from Mapping + */ +final class CacheFilesystem implements Filesystem +{ + use DecoratedFilesystem; + + private Mapping $mapping; + + /** + * @param Format $metadata + */ + public function __construct( + private Filesystem $inner, + private CacheItemPoolInterface $cache, + array|string $metadata, + private ?int $ttl = null, + ) { + // ensure PATH is always included + if (\is_string($metadata) && Mapping::PATH !== $metadata) { + $metadata = \array_merge((array) $metadata, [Mapping::PATH]); + } + + if (\is_array($metadata) && !\in_array(Mapping::PATH, $metadata, true)) { + $metadata[] = Mapping::PATH; + } + + $this->mapping = new Mapping($metadata, filesystem: '__none__'); // dummy filesystem as it's required if not using a dsn (todo: remove this requirement) + } + + public function node(string $path): File|Directory + { + $item = $this->cache->getItem($this->cacheKey($path)); + + if ($item->isHit() && $file = $this->unserialize($item->get())) { + return $file; + } + + $node = $this->inner->node($path); + + if ($node instanceof Directory) { + // directories are not cached + return $node; + } + + return $this->cache($node, $item); + } + + public function file(string $path): File + { + return $this->node($path)->ensureFile(); + } + + public function image(string $path): Image + { + return $this->node($path)->ensureImage(); + } + + public function has(string $path): bool + { + if ($this->cache->hasItem($this->cacheKey($path))) { + return true; + } + + return $this->inner->has($path); + } + + public function copy(string $source, string $destination, array $config = []): File + { + return $this->cache($this->inner->copy($source, $destination, $config)); + } + + public function move(string $source, string $destination, array $config = []): File + { + try { + return $this->cache($this->inner->move($source, $destination, $config)); + } finally { + $this->cache->deleteItem($this->cacheKey($source)); + } + } + + public function delete(string $path, array $config = []): self + { + $this->inner->delete($path, $config); + $this->cache->deleteItem($this->cacheKey($path)); + + return $this; + } + + public function chmod(string $path, string $visibility): File|Directory + { + $node = $this->inner->chmod($path, $visibility); + + if ($node instanceof Directory) { + return $node; + } + + return $this->cache($node); + } + + public function write(string $path, mixed $value, array $config = []): File + { + return $this->cache($this->inner->write($path, $value, $config)); + } + + protected function inner(): Filesystem + { + return $this->inner; + } + + private function cache(File $file, ?CacheItemInterface $item = null): File + { + $item ??= $this->cache->getItem($this->cacheKey($file->path())); + + if ($this->ttl) { + $item->expiresAfter($this->ttl); + } + + if ($this->cache instanceof TagAwareCacheInterface && $item instanceof ItemInterface) { + $item->tag(['filesystem', "filesystem.{$this->name()}"]); + } + + $this->cache->save($item->set($this->serialize($file))); + + return $file; + } + + /** + * @return array{Serialized,bool} + */ + private function serialize(File $file): array + { + return [$this->mapping->serialize($file), $file->isImage()]; + } + + private function unserialize(mixed $value): ?File + { + if (!\is_array($value) || 2 !== \count($parts = $value)) { + return null; + } + + [$data, $isImage] = $parts; + + $file = $isImage ? new LazyImage($data) : new LazyFile($data); + $file->setFilesystem($this->inner); + + return $file; + } + + private function cacheKey(string $path): string + { + return \sprintf('filesystem.%s.%s', $this->name(), \str_replace('/', '--', $path)); + } +} diff --git a/src/Filesystem/Symfony/DependencyInjection/Configuration.php b/src/Filesystem/Symfony/DependencyInjection/Configuration.php index acbacf3..4a2e418 100644 --- a/src/Filesystem/Symfony/DependencyInjection/Configuration.php +++ b/src/Filesystem/Symfony/DependencyInjection/Configuration.php @@ -244,6 +244,26 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->arrayNode('cache') + ->info('Cache file/image metadata') + ->canBeEnabled() + ->children() + ->scalarNode('pool') + ->info('PSR-6 cache pool service id') + ->defaultValue('cache.app') + ->cannotBeEmpty() + ->end() + ->integerNode('ttl') + ->info('Cache TTL (null for no TTL)') + ->defaultValue(null) + ->end() + ->variableNode('metadata') + ->info(\sprintf('File/image metadata to cache (see %s)', Mapping::class)) + ->cannotBeEmpty() + ->example([Mapping::LAST_MODIFIED, Mapping::SIZE, Mapping::DIMENSIONS]) + ->end() + ->end() + ->end() ->arrayNode('events') ->info('Dispatch filesystem operation events') ->canBeEnabled() diff --git a/tests/Filesystem/CacheFilesystemTest.php b/tests/Filesystem/CacheFilesystemTest.php new file mode 100644 index 0000000..5668f75 --- /dev/null +++ b/tests/Filesystem/CacheFilesystemTest.php @@ -0,0 +1,118 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Tests\Filesystem; + +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Zenstruck\Filesystem; +use Zenstruck\Filesystem\CacheFilesystem; +use Zenstruck\Filesystem\Node\File\Image; +use Zenstruck\Filesystem\Node\Mapping; +use Zenstruck\Tests\FilesystemTest; + +/** + * @author Kevin Bond + */ +final class CacheFilesystemTest extends FilesystemTest +{ + /** + * @test + */ + public function files_are_cached_on_write(): void + { + $cacheFs = $this->createFilesystem($fs = in_memory_filesystem()); + $cacheFs->write('some/file.txt', 'content'); + + // delete inner filesystem file + $fs->delete('some/file.txt'); + + // file should still be in cache + $this->assertTrue($cacheFs->has('some/file.txt')); + $this->assertSame(7, $cacheFs->node('some/file.txt')->size()); + $this->assertFalse($cacheFs->node('some/file.txt')->exists()); + } + + /** + * @test + */ + public function files_are_cached_on_fetch(): void + { + $cacheFs = $this->createFilesystem($fs = in_memory_filesystem()); + $fs->write('some/file.txt', 'content'); + + // fetch file and cache + $cacheFs->file('some/file.txt'); + + // delete inner filesystem file + $fs->delete('some/file.txt'); + + // file should still be in cache + $this->assertTrue($cacheFs->has('some/file.txt')); + $this->assertSame(7, $cacheFs->node('some/file.txt')->size()); + $this->assertFalse($cacheFs->node('some/file.txt')->exists()); + } + + /** + * @test + */ + public function images_are_cached_on_write(): void + { + $cacheFs = $this->createFilesystem($fs = in_memory_filesystem()); + $cacheFs->write('some/file.png', fixture('symfony.png')); + + $fs->delete('some/file.png'); + + // image should still be in cache + $node = $cacheFs->node('some/file.png'); + $this->assertSame(10862, $node->size()); + $this->assertInstanceOf(Image::class, $node); + $this->assertSame(['width' => 563, 'height' => 678], $node->dimensions()->jsonSerialize()); + $this->assertFalse($node->exists()); + } + + /** + * @test + */ + public function images_are_cached_on_fetch(): void + { + $cacheFs = $this->createFilesystem($fs = in_memory_filesystem()); + $fs->write('some/file.png', fixture('symfony.png')); + + $cacheFs->image('some/file.png'); + + $fs->delete('some/file.png'); + + // image should still be in cache + $node = $cacheFs->node('some/file.png'); + $this->assertSame(10862, $node->size()); + $this->assertInstanceOf(Image::class, $node); + $this->assertSame(['width' => 563, 'height' => 678], $node->dimensions()->jsonSerialize()); + $this->assertFalse($node->exists()); + } + + protected function createFilesystem( + ?Filesystem $inner = null, + ?CacheItemPoolInterface $cache = null, + array $metadata = [ + Mapping::SIZE, + Mapping::LAST_MODIFIED, + Mapping::MIME_TYPE, + Mapping::DIMENSIONS, + ], + ): Filesystem { + return new CacheFilesystem( + $inner ?? in_memory_filesystem(), + $cache ?? new ArrayAdapter(), + $metadata + ); + } +} diff --git a/tests/FilesystemTest.php b/tests/FilesystemTest.php index fa26b39..b42358a 100644 --- a/tests/FilesystemTest.php +++ b/tests/FilesystemTest.php @@ -81,7 +81,7 @@ public function invalid_file(): void public function can_get_image(): void { $fs = $this->createFilesystem(); - $fs->write('some/file.png', 'content'); + $fs->write('some/file.png', fixture('symfony.png')); $this->assertTrue($fs->image('some/file.png')->exists()); }