Skip to content

Commit

Permalink
feat: CacheFilesystem
Browse files Browse the repository at this point in the history
  • Loading branch information
kbond committed May 1, 2024
1 parent 1192f87 commit 26603f8
Show file tree
Hide file tree
Showing 5 changed files with 371 additions and 2 deletions.
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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';
}
Expand Down Expand Up @@ -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
Expand Down
182 changes: 182 additions & 0 deletions src/Filesystem/CacheFilesystem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
<?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\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 <[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 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));
}
}
20 changes: 20 additions & 0 deletions src/Filesystem/Symfony/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading

0 comments on commit 26603f8

Please sign in to comment.