Skip to content

Commit 26603f8

Browse files
committed
feat: CacheFilesystem
1 parent 1192f87 commit 26603f8

File tree

5 files changed

+371
-2
lines changed

5 files changed

+371
-2
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: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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+
return $this;
116+
}
117+
118+
public function chmod(string $path, string $visibility): File|Directory
119+
{
120+
$node = $this->inner->chmod($path, $visibility);
121+
122+
if ($node instanceof Directory) {
123+
return $node;
124+
}
125+
126+
return $this->cache($node);
127+
}
128+
129+
public function write(string $path, mixed $value, array $config = []): File
130+
{
131+
return $this->cache($this->inner->write($path, $value, $config));
132+
}
133+
134+
protected function inner(): Filesystem
135+
{
136+
return $this->inner;
137+
}
138+
139+
private function cache(File $file, ?CacheItemInterface $item = null): File
140+
{
141+
$item ??= $this->cache->getItem($this->cacheKey($file->path()));
142+
143+
if ($this->ttl) {
144+
$item->expiresAfter($this->ttl);
145+
}
146+
147+
if ($this->cache instanceof TagAwareCacheInterface && $item instanceof ItemInterface) {
148+
$item->tag(['filesystem', "filesystem.{$this->name()}"]);
149+
}
150+
151+
$this->cache->save($item->set($this->serialize($file)));
152+
153+
return $file;
154+
}
155+
156+
/**
157+
* @return array{Serialized,bool}
158+
*/
159+
private function serialize(File $file): array
160+
{
161+
return [$this->mapping->serialize($file), $file->isImage()];
162+
}
163+
164+
private function unserialize(mixed $value): ?File
165+
{
166+
if (!\is_array($value) || 2 !== \count($parts = $value)) {
167+
return null;
168+
}
169+
170+
[$data, $isImage] = $parts;
171+
172+
$file = $isImage ? new LazyImage($data) : new LazyFile($data);
173+
$file->setFilesystem($this->inner);
174+
175+
return $file;
176+
}
177+
178+
private function cacheKey(string $path): string
179+
{
180+
return \sprintf('filesystem.%s.%s', $this->name(), \str_replace('/', '--', $path));
181+
}
182+
}

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')
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()

0 commit comments

Comments
 (0)