diff --git a/src/Filesystem/CacheFilesystem.php b/src/Filesystem/CacheFilesystem.php new file mode 100644 index 0000000..55d2c86 --- /dev/null +++ b/src/Filesystem/CacheFilesystem.php @@ -0,0 +1,180 @@ + + * + * 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\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 cacheable + return $node; + } + + 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($node))); + + return $node; + } + + 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 + { + try { + return $this->inner->copy($source, $destination, $config); + } finally { + $this->cache->deleteItem($this->cacheKey($destination)); + } + } + + public function move(string $source, string $destination, array $config = []): File + { + try { + return $this->inner->move($source, $destination, $config); + } finally { + $this->cache->deleteItems([$this->cacheKey($source), $this->cacheKey($destination)]); + } + } + + 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 + { + try { + return $this->inner->chmod($path, $visibility); + } finally { + $this->cache->deleteItem($this->cacheKey($path)); + } + } + + public function write(string $path, mixed $value, array $config = []): File + { + try { + return $this->inner->write($path, $value, $config); + } finally { + $this->cache->deleteItem($this->cacheKey($path)); + } + } + + protected function inner(): Filesystem + { + return $this->inner; + } + + /** + * @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/tests/Filesystem/CacheFilesystemTest.php b/tests/Filesystem/CacheFilesystemTest.php new file mode 100644 index 0000000..7810714 --- /dev/null +++ b/tests/Filesystem/CacheFilesystemTest.php @@ -0,0 +1,82 @@ + + * + * 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(): void + { + $cacheFs = $this->createFilesystem($fs = in_memory_filesystem()); + $cacheFs->write('some/file.txt', 'content'); + + // first call caches the file's metadata + $this->assertSame(7, $cacheFs->node('some/file.txt')->size()); + + // second call uses the cache + $this->assertSame(7, $cacheFs->node('some/file.txt')->size()); + + // 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()); + } + + /** + * @test + */ + public function images_are_cached(): void + { + $fs = $this->createFilesystem(in_memory_filesystem()); + $fs->write('some/file.png', 'content'); + + // first call caches the file's metadata + $node = $fs->node('some/file.png'); + $this->assertSame(7, $node->size()); + + // second call uses the cache and should return image type + $node = $fs->node('some/file.png'); + $this->assertSame(7, $node->size()); + $this->assertInstanceOf(Image::class, $node); + } + + protected function createFilesystem( + ?Filesystem $inner = null, + ?CacheItemPoolInterface $cache = null, + array $metadata = [ + Mapping::SIZE, + Mapping::LAST_MODIFIED, + Mapping::MIME_TYPE, + ], + ): Filesystem { + return new CacheFilesystem( + $inner ?? in_memory_filesystem(), + $cache ?? new ArrayAdapter(), + $metadata + ); + } +}