From 26603f88c5282506c002967f80356f6a8823d285 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Thu, 28 Mar 2024 18:35:29 -0400 Subject: [PATCH 1/2] feat: `CacheFilesystem` --- README.md | 51 ++++- src/Filesystem/CacheFilesystem.php | 182 ++++++++++++++++++ .../DependencyInjection/Configuration.php | 20 ++ tests/Filesystem/CacheFilesystemTest.php | 118 ++++++++++++ tests/FilesystemTest.php | 2 +- 5 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 src/Filesystem/CacheFilesystem.php create mode 100644 tests/Filesystem/CacheFilesystemTest.php diff --git a/README.md b/README.md index f6bdb598..9cd351e0 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 00000000..1f7b64ab --- /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 acbacf3d..4a2e4183 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 00000000..5668f75c --- /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 fa26b39d..b42358ad 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()); } From 258f43da67be7f04e07c0342b822613a5d4f9790 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Wed, 1 May 2024 16:21:01 -0400 Subject: [PATCH 2/2] wip --- .../ZenstruckFilesystemExtension.php | 13 +++++++++++++ .../Symfony/ZenstruckFilesystemBundleTest.php | 11 +++++++++++ tests/Fixtures/TestKernel.php | 4 ++++ 3 files changed, 28 insertions(+) diff --git a/src/Filesystem/Symfony/DependencyInjection/ZenstruckFilesystemExtension.php b/src/Filesystem/Symfony/DependencyInjection/ZenstruckFilesystemExtension.php index a01bdb42..210f311d 100644 --- a/src/Filesystem/Symfony/DependencyInjection/ZenstruckFilesystemExtension.php +++ b/src/Filesystem/Symfony/DependencyInjection/ZenstruckFilesystemExtension.php @@ -30,6 +30,7 @@ use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Contracts\Translation\LocaleAwareInterface; use Zenstruck\Filesystem; +use Zenstruck\Filesystem\CacheFilesystem; use Zenstruck\Filesystem\Doctrine\EventListener\NodeLifecycleListener; use Zenstruck\Filesystem\Doctrine\EventListener\NodeMappingListener; use Zenstruck\Filesystem\Doctrine\MappingManager; @@ -434,6 +435,18 @@ private function registerFilesystem(string $name, array $config, ContainerBuilde ; } + if ($config['cache']['enabled']) { + $container->register('.zenstruck_filesystem.filesystem.cache_'.$name, CacheFilesystem::class) + ->setDecoratedService($filesystemId) + ->setArguments([ + new Reference('.inner'), + new Reference($config['cache']['pool']), + $config['cache']['metadata'], + $config['cache']['ttl'], + ]) + ; + } + if ($container->getParameter('kernel.debug')) { $container->register('.zenstruck_filesystem.filesystem.traceable_'.$name, TraceableFilesystem::class) ->setDecoratedService($filesystemId) diff --git a/tests/Filesystem/Symfony/ZenstruckFilesystemBundleTest.php b/tests/Filesystem/Symfony/ZenstruckFilesystemBundleTest.php index 2f0ccad5..7bc59304 100644 --- a/tests/Filesystem/Symfony/ZenstruckFilesystemBundleTest.php +++ b/tests/Filesystem/Symfony/ZenstruckFilesystemBundleTest.php @@ -152,4 +152,15 @@ public function static_in_memory_filesystem(): void $this->filesystem()->assertExists('static://file.txt'); } + + /** + * @test + */ + public function cached_filesystem(): void + { + /** @var Service $service */ + $service = self::getContainer()->get(Service::class); + + $this->markTestIncomplete(); + } } diff --git a/tests/Fixtures/TestKernel.php b/tests/Fixtures/TestKernel.php index afb2071b..44706f0b 100644 --- a/tests/Fixtures/TestKernel.php +++ b/tests/Fixtures/TestKernel.php @@ -26,6 +26,7 @@ use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; use Zenstruck\Filesystem\Glide\GlideTransformUrlGenerator; +use Zenstruck\Filesystem\Node\Mapping; use Zenstruck\Filesystem\Symfony\Form\PendingFileType; use Zenstruck\Filesystem\Symfony\Form\PendingImageType; use Zenstruck\Filesystem\Symfony\ZenstruckFilesystemBundle; @@ -128,6 +129,9 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load 'image_url' => 'route:public_transform', 'reset_before_tests' => true, 'events' => true, + 'cache' => [ + 'metadata' => [Mapping::LAST_MODIFIED, Mapping::SIZE] + ], ], 'private' => [ 'dsn' => '%kernel.project_dir%/var/private',