Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: CacheFilesystem #109

Draft
wants to merge 2 commits into
base: 1.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading