Skip to content

Commit 1ff7cdc

Browse files
committed
feat: CacheFilesystem
1 parent 613885d commit 1ff7cdc

File tree

2 files changed

+262
-0
lines changed

2 files changed

+262
-0
lines changed

src/Filesystem/CacheFilesystem.php

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

0 commit comments

Comments
 (0)