diff --git a/src/Cache/ChainCache.php b/src/Cache/ChainCache.php new file mode 100644 index 000000000..18c66f35f --- /dev/null +++ b/src/Cache/ChainCache.php @@ -0,0 +1,81 @@ + + */ +final class ChainCache implements CacheInterface +{ + private $caches; + + /** + * @param iterable $caches The ordered list of caches used to store and fetch cached items + */ + public function __construct(iterable $caches) + { + $this->caches = $caches; + } + + public function generateKey(string $name, string $className): string + { + return $className.'#'.$name; + } + + public function write(string $key, string $content): void + { + $splitKey = $this->splitKey($key); + + foreach ($this->caches as $cache) { + $cache->write($cache->generateKey(...$splitKey), $content); + } + } + + public function load(string $key): void + { + [$name, $className] = $this->splitKey($key); + + foreach ($this->caches as $cache) { + $cache->load($cache->generateKey($name, $className)); + + if (class_exists($className, false)) { + break; + } + } + } + + public function getTimestamp(string $key): int + { + $splitKey = $this->splitKey($key); + + foreach ($this->caches as $cache) { + if (0 < $timestamp = $cache->getTimestamp($cache->generateKey(...$splitKey))) { + return $timestamp; + } + } + + return 0; + } + + /** + * @return string[] + */ + private function splitKey(string $key): array + { + return array_reverse(explode('#', $key, 2)); + } +} diff --git a/src/Cache/ReadOnlyFilesystemCache.php b/src/Cache/ReadOnlyFilesystemCache.php new file mode 100644 index 000000000..3ba6514c9 --- /dev/null +++ b/src/Cache/ReadOnlyFilesystemCache.php @@ -0,0 +1,25 @@ + + */ +class ReadOnlyFilesystemCache extends FilesystemCache +{ + public function write(string $key, string $content): void + { + // Do nothing with the content, it's a read-only filesystem. + } +} diff --git a/tests/Cache/ChainTest.php b/tests/Cache/ChainTest.php new file mode 100644 index 000000000..f87e16293 --- /dev/null +++ b/tests/Cache/ChainTest.php @@ -0,0 +1,231 @@ +classname = '__Twig_Tests_Cache_ChainTest_Template_'.$nonce; + $this->directory = sys_get_temp_dir().'/twig-test'; + $this->cache = new ChainCache([ + new FilesystemCache($this->directory.'/A'), + new FilesystemCache($this->directory.'/B'), + ]); + $this->key = $this->cache->generateKey('_test_', $this->classname); + } + + protected function tearDown(): void + { + if (file_exists($this->directory)) { + FilesystemHelper::removeDir($this->directory); + } + } + + public function testLoadInA() + { + $cache = new FilesystemCache($this->directory.'/A'); + $key = $cache->generateKey('_test_', $this->classname); + + $dir = \dirname($key); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + $this->assertFalse(class_exists($this->classname, false)); + + $content = $this->generateSource(); + file_put_contents($key, $content); + var_dump($key); + + $this->cache->load($this->key); + + $this->assertTrue(class_exists($this->classname, false)); + } + + public function testLoadInB() + { + $cache = new FilesystemCache($this->directory.'/B'); + $key = $cache->generateKey('_test_', $this->classname); + + $dir = \dirname($key); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + $this->assertFalse(class_exists($this->classname, false)); + + $content = $this->generateSource(); + file_put_contents($key, $content); + var_dump($key); + + $this->cache->load($this->key); + + $this->assertTrue(class_exists($this->classname, false)); + } + + public function testLoadInBoth() + { + $cache = new FilesystemCache($this->directory.'/A'); + $key = $cache->generateKey('_test_', $this->classname); + + $dir = \dirname($key); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + $this->assertFalse(class_exists($this->classname, false)); + + $content = $this->generateSource(); + file_put_contents($key, $content); + + $cache = new FilesystemCache($this->directory.'/B'); + $key = $cache->generateKey('_test_', $this->classname); + + $dir = \dirname($key); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + $this->assertFalse(class_exists($this->classname, false)); + + $content = $this->generateSource(); + file_put_contents($key, $content); + + $this->cache->load($this->key); + + $this->assertTrue(class_exists($this->classname, false)); + } + + public function testLoadMissing() + { + $this->assertFalse(class_exists($this->classname, false)); + + $this->cache->load($this->key); + + $this->assertFalse(class_exists($this->classname, false)); + } + + public function testWrite() + { + $content = $this->generateSource(); + + $cacheA = new FilesystemCache($this->directory.'/A'); + $keyA = $cacheA->generateKey('_test_', $this->classname); + + $this->assertFileDoesNotExist($keyA); + $this->assertFileDoesNotExist($this->directory.'/A'); + + $cacheB = new FilesystemCache($this->directory.'/B'); + $keyB = $cacheB->generateKey('_test_', $this->classname); + + $this->assertFileDoesNotExist($keyB); + $this->assertFileDoesNotExist($this->directory.'/B'); + + $this->cache->write($this->key, $content); + + $this->assertFileExists($this->directory.'/A'); + $this->assertFileExists($keyA); + $this->assertSame(file_get_contents($keyA), $content); + + $this->assertFileExists($this->directory.'/B'); + $this->assertFileExists($keyB); + $this->assertSame(file_get_contents($keyB), $content); + } + + public function testGetTimestampInA() + { + $cache = new FilesystemCache($this->directory.'/A'); + $key = $cache->generateKey('_test_', $this->classname); + + $dir = \dirname($key); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + + // Create the file with a specific modification time. + touch($key, 1234567890); + + $this->assertSame(1234567890, $this->cache->getTimestamp($this->key)); + } + + public function testGetTimestampInB() + { + $cache = new FilesystemCache($this->directory.'/B'); + $key = $cache->generateKey('_test_', $this->classname); + + $dir = \dirname($key); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + + // Create the file with a specific modification time. + touch($key, 1234567890); + + $this->assertSame(1234567890, $this->cache->getTimestamp($this->key)); + } + + public function testGetTimestampInBoth() + { + $cacheA = new FilesystemCache($this->directory.'/A'); + $keyA = $cacheA->generateKey('_test_', $this->classname); + + $dir = \dirname($keyA); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + + // Create the file with a specific modification time. + touch($keyA, 1234567890); + + $cacheB = new FilesystemCache($this->directory.'/B'); + $keyB = $cacheB->generateKey('_test_', $this->classname); + + $dir = \dirname($keyB); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + + // Create the file with a specific modification time. + touch($keyB, 1234567891); + + $this->assertSame(1234567890, $this->cache->getTimestamp($this->key)); + } + + public function testGetTimestampMissingFile() + { + $this->assertSame(0, $this->cache->getTimestamp($this->key)); + } + + /** + * @dataProvider provideInput + */ + public function testGenerateKey($expected, $input) + { + $cache = new ChainCache([]); + $this->assertSame($expected, $cache->generateKey($input, static::class)); + } + + public static function provideInput() + { + return [ + ['Twig\Tests\Cache\ChainTest#_test_', '_test_'], + ['Twig\Tests\Cache\ChainTest#_test#with#hashtag_', '_test#with#hashtag_'], + ]; + } + + private function generateSource() + { + return strtr(' $this->classname, + ]); + } +} diff --git a/tests/Cache/ReadOnlyFilesystemTest.php b/tests/Cache/ReadOnlyFilesystemTest.php new file mode 100644 index 000000000..fe6dffcdb --- /dev/null +++ b/tests/Cache/ReadOnlyFilesystemTest.php @@ -0,0 +1,132 @@ +classname = '__Twig_Tests_Cache_ReadOnlyFilesystemTest_Template_'.$nonce; + $this->directory = sys_get_temp_dir().'/twig-test'; + $this->cache = new ReadOnlyFilesystemCache($this->directory); + } + + protected function tearDown(): void + { + if (file_exists($this->directory)) { + FilesystemHelper::removeDir($this->directory); + } + } + + public function testLoad() + { + $key = $this->directory.'/cache/ro-cachefile.php'; + + $dir = \dirname($key); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + $this->assertFalse(class_exists($this->classname, false)); + + $content = $this->generateSource(); + file_put_contents($key, $content); + + $this->cache->load($key); + + $this->assertTrue(class_exists($this->classname, false)); + } + + public function testLoadMissing() + { + $key = $this->directory.'/cache/cachefile.php'; + + $this->assertFalse(class_exists($this->classname, false)); + + $this->cache->load($key); + + $this->assertFalse(class_exists($this->classname, false)); + } + + public function testWrite() + { + $key = $this->directory.'/cache/cachefile.php'; + $content = $this->generateSource(); + + $this->assertFileDoesNotExist($key); + $this->assertFileDoesNotExist($this->directory); + + $this->cache->write($key, $content); + + $this->assertFileDoesNotExist($this->directory); + $this->assertFileDoesNotExist($key); + } + + public function testGetTimestamp() + { + $key = $this->directory.'/cache/cachefile.php'; + + $dir = \dirname($key); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + + // Create the file with a specific modification time. + touch($key, 1234567890); + + $this->assertSame(1234567890, $this->cache->getTimestamp($key)); + } + + public function testGetTimestampMissingFile() + { + $key = $this->directory.'/cache/cachefile.php'; + $this->assertSame(0, $this->cache->getTimestamp($key)); + } + + /** + * Test file cache is tolerant towards trailing (back)slashes on the configured cache directory. + * + * @dataProvider provideDirectories + */ + public function testGenerateKey($expected, $input) + { + $cache = new ReadOnlyFilesystemCache($input); + $this->assertMatchesRegularExpression($expected, $cache->generateKey('_test_', static::class)); + } + + public static function provideDirectories() + { + $pattern = '#a/b/[a-zA-Z0-9]+/[a-zA-Z0-9]+.php$#'; + + return [ + [$pattern, 'a/b'], + [$pattern, 'a/b/'], + [$pattern, 'a/b\\'], + [$pattern, 'a/b\\/'], + [$pattern, 'a/b\\//'], + ['#/'.substr($pattern, 1), '/a/b'], + ]; + } + + private function generateSource() + { + return strtr(' $this->classname, + ]); + } +}