Skip to content

Commit d9ba252

Browse files
authored
feat: CacheFilesystem (#109)
1 parent f0d548f commit d9ba252

File tree

8 files changed

+429
-6
lines changed

8 files changed

+429
-6
lines changed

README.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,37 @@ $filesystem = new MultiFilesystem(
369369
$filesystem->file('another/file.txt'); // File from "filesystem2"
370370
```
371371

372+
### `CacheFilesystem`
373+
374+
> [!NOTE]
375+
> A `psr/cache-implementation` is required.
376+
377+
```php
378+
use Zenstruck\Filesystem\CacheFilesystem;
379+
use Zenstruck\Filesystem\Node\Mapping;
380+
381+
/** @var \Zenstruck\Filesystem $inner */
382+
/** @var \Psr\Cache\CacheItemPoolInterface $cache */
383+
384+
$filesystem = new CacheFilesystem(
385+
inner: $inner,
386+
cache: $cache,
387+
metadata: [ // array of metadata to cache (see Zenstruck\Filesystem\Node\Mapping)
388+
Mapping::LAST_MODIFIED,
389+
Mapping::SIZE,
390+
],
391+
ttl: 3600, // or null for no TTL
392+
);
393+
394+
$filesystem->write('file.txt', 'content'); // caches metadata
395+
396+
$file = $filesystem->file('file.txt');
397+
$file->lastModified(); // cached value
398+
$file->size(); // cached value
399+
$file->checksum(); // real value (as this wasn't configured to be cached)
400+
$file->contents(); // actually reads the file (contents cannot be cached)
401+
```
402+
372403
### `LoggableFilesystem`
373404

374405
> [!NOTE]
@@ -645,7 +676,7 @@ class MyTest extends TestCase implements FilesystemProvider
645676
$filesystem->assertExists('file.txt');
646677
}
647678

648-
public function createFilesystem(): Filesystem|FilesystemAdapter|string;
679+
public function createFilesystem(): Filesystem|FilesystemAdapter|string
649680
{
650681
return '/some/temp/dir';
651682
}
@@ -925,6 +956,24 @@ zenstruck_filesystem:
925956
# Default expiry
926957
expires: null # Example: '+ 30 minutes'
927958

959+
# Cache file/image metadata
960+
cache:
961+
enabled: false
962+
963+
# PSR-6 cache pool service id
964+
pool: cache.app
965+
966+
# Cache TTL (null for no TTL)
967+
ttl: null
968+
969+
# File/image metadata to cache (see Zenstruck\Filesystem\Node\Mapping)
970+
metadata: ~
971+
972+
# Examples:
973+
# - last_modified
974+
# - size
975+
# - dimensions
976+
928977
# Dispatch filesystem operation events
929978
events:
930979
enabled: false

src/Filesystem/CacheFilesystem.php

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the zenstruck/filesystem package.
5+
*
6+
* (c) Kevin Bond <kevinbond@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Zenstruck\Filesystem;
13+
14+
use Psr\Cache\CacheItemInterface;
15+
use Psr\Cache\CacheItemPoolInterface;
16+
use Symfony\Contracts\Cache\ItemInterface;
17+
use Symfony\Contracts\Cache\TagAwareCacheInterface;
18+
use Zenstruck\Filesystem;
19+
use Zenstruck\Filesystem\Node\Directory;
20+
use Zenstruck\Filesystem\Node\File;
21+
use Zenstruck\Filesystem\Node\File\Image;
22+
use Zenstruck\Filesystem\Node\File\Image\LazyImage;
23+
use Zenstruck\Filesystem\Node\File\LazyFile;
24+
use Zenstruck\Filesystem\Node\Mapping;
25+
26+
/**
27+
* @author Kevin Bond <kevinbond@gmail.com>
28+
*
29+
* @phpstan-import-type Format from Mapping
30+
* @phpstan-import-type Serialized from Mapping
31+
*/
32+
final class CacheFilesystem implements Filesystem
33+
{
34+
use DecoratedFilesystem;
35+
36+
private Mapping $mapping;
37+
38+
/**
39+
* @param Format $metadata
40+
*/
41+
public function __construct(
42+
private Filesystem $inner,
43+
private CacheItemPoolInterface $cache,
44+
array|string $metadata,
45+
private ?int $ttl = null,
46+
) {
47+
// ensure PATH is always included
48+
if (\is_string($metadata) && Mapping::PATH !== $metadata) {
49+
$metadata = \array_merge((array) $metadata, [Mapping::PATH]);
50+
}
51+
52+
if (\is_array($metadata) && !\in_array(Mapping::PATH, $metadata, true)) {
53+
$metadata[] = Mapping::PATH;
54+
}
55+
56+
$this->mapping = new Mapping($metadata, filesystem: '__none__'); // dummy filesystem as it's required if not using a dsn (todo: remove this requirement)
57+
}
58+
59+
public function node(string $path): File|Directory
60+
{
61+
$item = $this->cache->getItem($this->cacheKey($path));
62+
63+
if ($item->isHit() && $file = $this->unserialize($item->get())) {
64+
return $file;
65+
}
66+
67+
$node = $this->inner->node($path);
68+
69+
if ($node instanceof Directory) {
70+
// directories are not cached
71+
return $node;
72+
}
73+
74+
return $this->cache($node, $item);
75+
}
76+
77+
public function file(string $path): File
78+
{
79+
return $this->node($path)->ensureFile();
80+
}
81+
82+
public function image(string $path): Image
83+
{
84+
return $this->node($path)->ensureImage();
85+
}
86+
87+
public function has(string $path): bool
88+
{
89+
if ($this->cache->hasItem($this->cacheKey($path))) {
90+
return true;
91+
}
92+
93+
return $this->inner->has($path);
94+
}
95+
96+
public function copy(string $source, string $destination, array $config = []): File
97+
{
98+
return $this->cache($this->inner->copy($source, $destination, $config));
99+
}
100+
101+
public function move(string $source, string $destination, array $config = []): File
102+
{
103+
try {
104+
return $this->cache($this->inner->move($source, $destination, $config));
105+
} finally {
106+
$this->cache->deleteItem($this->cacheKey($source));
107+
}
108+
}
109+
110+
public function delete(string $path, array $config = []): self
111+
{
112+
$this->inner->delete($path, $config);
113+
$this->cache->deleteItem($this->cacheKey($path));
114+
115+
if ('' === $path && $this->cache instanceof TagAwareCacheInterface) {
116+
$this->cache->invalidateTags(["filesystem.{$this->name()}"]);
117+
}
118+
119+
return $this;
120+
}
121+
122+
public function chmod(string $path, string $visibility): File|Directory
123+
{
124+
$node = $this->inner->chmod($path, $visibility);
125+
126+
if ($node instanceof Directory) {
127+
return $node;
128+
}
129+
130+
return $this->cache($node);
131+
}
132+
133+
public function write(string $path, mixed $value, array $config = []): File
134+
{
135+
return $this->cache($this->inner->write($path, $value, $config));
136+
}
137+
138+
protected function inner(): Filesystem
139+
{
140+
return $this->inner;
141+
}
142+
143+
private function cache(File $file, ?CacheItemInterface $item = null): File
144+
{
145+
$item ??= $this->cache->getItem($this->cacheKey($file->path()));
146+
147+
if ($this->ttl) {
148+
$item->expiresAfter($this->ttl);
149+
}
150+
151+
if ($this->cache instanceof TagAwareCacheInterface && $item instanceof ItemInterface) {
152+
$item->tag(['filesystem', "filesystem.{$this->name()}"]);
153+
}
154+
155+
$this->cache->save($item->set($this->serialize($file)));
156+
157+
return $file;
158+
}
159+
160+
/**
161+
* @return array{Serialized,bool}
162+
*/
163+
private function serialize(File $file): array
164+
{
165+
return [$this->mapping->serialize($file), $file->isImage()];
166+
}
167+
168+
private function unserialize(mixed $value): ?File
169+
{
170+
if (!\is_array($value) || 2 !== \count($parts = $value)) {
171+
return null;
172+
}
173+
174+
[$data, $isImage] = $parts;
175+
176+
$file = $isImage ? new LazyImage($data) : new LazyFile($data);
177+
$file->setFilesystem($this->inner);
178+
179+
return $file;
180+
}
181+
182+
private function cacheKey(string $path): string
183+
{
184+
return \sprintf('filesystem.%s.%s', $this->name(), \str_replace('/', '--', $path));
185+
}
186+
}

src/Filesystem/Symfony/DependencyInjection/Configuration.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,26 @@ public function getConfigTreeBuilder(): TreeBuilder
244244
->end()
245245
->end()
246246
->end()
247+
->arrayNode('cache')
248+
->info('Cache file/image metadata')
249+
->canBeEnabled()
250+
->children()
251+
->scalarNode('pool')
252+
->info('PSR-6 cache pool service id')
253+
->defaultValue('cache.app.taggable')
254+
->cannotBeEmpty()
255+
->end()
256+
->integerNode('ttl')
257+
->info('Cache TTL (null for no TTL)')
258+
->defaultValue(null)
259+
->end()
260+
->variableNode('metadata')
261+
->info(\sprintf('File/image metadata to cache (see %s)', Mapping::class))
262+
->cannotBeEmpty()
263+
->example([Mapping::LAST_MODIFIED, Mapping::SIZE, Mapping::DIMENSIONS])
264+
->end()
265+
->end()
266+
->end()
247267
->arrayNode('events')
248268
->info('Dispatch filesystem operation events')
249269
->canBeEnabled()

src/Filesystem/Symfony/DependencyInjection/ZenstruckFilesystemExtension.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
use Symfony\Component\Validator\Validator\ValidatorInterface;
3434
use Symfony\Contracts\Translation\LocaleAwareInterface;
3535
use Zenstruck\Filesystem;
36+
use Zenstruck\Filesystem\CacheFilesystem;
3637
use Zenstruck\Filesystem\Doctrine\EventListener\NodeLifecycleListener;
3738
use Zenstruck\Filesystem\Doctrine\EventListener\NodeMappingListener;
3839
use Zenstruck\Filesystem\Doctrine\MappingManager;
@@ -431,6 +432,18 @@ private function registerFilesystem(string $name, array $config, ContainerBuilde
431432
;
432433
}
433434

435+
if ($config['cache']['enabled']) {
436+
$container->register('.zenstruck_filesystem.filesystem.cache_'.$name, CacheFilesystem::class)
437+
->setDecoratedService($filesystemId)
438+
->setArguments([
439+
new Reference('.inner'),
440+
new Reference($config['cache']['pool']),
441+
$config['cache']['metadata'],
442+
$config['cache']['ttl'],
443+
])
444+
;
445+
}
446+
434447
if ($container->getParameter('kernel.debug')) {
435448
$container->register('.zenstruck_filesystem.filesystem.traceable_'.$name, TraceableFilesystem::class)
436449
->setDecoratedService($filesystemId)

0 commit comments

Comments
 (0)