Skip to content

Commit

Permalink
feat: CacheFilesystem
Browse files Browse the repository at this point in the history
  • Loading branch information
kbond committed Apr 11, 2024
1 parent 613885d commit 1ff7cdc
Show file tree
Hide file tree
Showing 2 changed files with 262 additions and 0 deletions.
180 changes: 180 additions & 0 deletions src/Filesystem/CacheFilesystem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
<?php

/*
* This file is part of the zenstruck/filesystem package.
*
* (c) Kevin Bond <[email protected]>
*
* 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 <[email protected]>
*
* @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));
}
}
82 changes: 82 additions & 0 deletions tests/Filesystem/CacheFilesystemTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

/*
* This file is part of the zenstruck/filesystem package.
*
* (c) Kevin Bond <[email protected]>
*
* 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 <[email protected]>
*/
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
);
}
}

0 comments on commit 1ff7cdc

Please sign in to comment.