diff --git a/CHANGELOG b/CHANGELOG index ea986469c..bb7d956ac 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # 3.15.0 (2024-XX-XX) + * Add template cache hot reload * Allow Twig callable argument names to be free-form (snake-case or camelCase) independently of the PHP callable signature They were automatically converted to snake-cased before * Deprecate the `attribute` function; use the `.` notation and wrap the name with parenthesis instead diff --git a/src/Cache/ChainCache.php b/src/Cache/ChainCache.php index c94afdb43..1c2098f1f 100644 --- a/src/Cache/ChainCache.php +++ b/src/Cache/ChainCache.php @@ -19,7 +19,7 @@ namespace Twig\Cache; * * @author Quentin Devos */ -final class ChainCache implements CacheInterface +final class ChainCache implements CacheInterface, RemovableCacheInterface { /** * @param iterable $caches The ordered list of caches used to store and fetch cached items @@ -69,6 +69,15 @@ final class ChainCache implements CacheInterface return 0; } + public function remove(string $name, string $cls): void + { + foreach ($this->caches as $cache) { + if ($cache instanceof RemovableCacheInterface) { + $cache->remove($name, $cls); + } + } + } + /** * @return string[] */ diff --git a/src/Cache/FilesystemCache.php b/src/Cache/FilesystemCache.php index 2e79fac05..5840585e3 100644 --- a/src/Cache/FilesystemCache.php +++ b/src/Cache/FilesystemCache.php @@ -16,7 +16,7 @@ namespace Twig\Cache; * * @author Andrew Tch */ -class FilesystemCache implements CacheInterface +class FilesystemCache implements CacheInterface, RemovableCacheInterface { public const FORCE_BYTECODE_INVALIDATION = 1; @@ -76,6 +76,14 @@ class FilesystemCache implements CacheInterface throw new \RuntimeException(\sprintf('Failed to write cache file "%s".', $key)); } + public function remove(string $name, string $cls): void + { + $key = $this->generateKey($name, $cls); + if (!@unlink($key) && file_exists($key)) { + throw new \RuntimeException(\sprintf('Failed to delete cache file "%s".', $key)); + } + } + public function getTimestamp(string $key): int { if (!is_file($key)) { diff --git a/src/Cache/NullCache.php b/src/Cache/NullCache.php index 8d20d59d8..1ae216928 100644 --- a/src/Cache/NullCache.php +++ b/src/Cache/NullCache.php @@ -16,7 +16,7 @@ namespace Twig\Cache; * * @author Fabien Potencier */ -final class NullCache implements CacheInterface +final class NullCache implements CacheInterface, RemovableCacheInterface { public function generateKey(string $name, string $className): string { @@ -35,4 +35,8 @@ final class NullCache implements CacheInterface { return 0; } + + public function remove(string $name, string $cls): void + { + } } diff --git a/src/Cache/RemovableCacheInterface.php b/src/Cache/RemovableCacheInterface.php new file mode 100644 index 000000000..05da56913 --- /dev/null +++ b/src/Cache/RemovableCacheInterface.php @@ -0,0 +1,20 @@ + + */ +interface RemovableCacheInterface +{ + public function remove(string $name, string $cls): void; +} diff --git a/src/Environment.php b/src/Environment.php index fe95adc57..c8862af3d 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -14,6 +14,7 @@ namespace Twig; use Twig\Cache\CacheInterface; use Twig\Cache\FilesystemCache; use Twig\Cache\NullCache; +use Twig\Cache\RemovableCacheInterface; use Twig\Error\Error; use Twig\Error\LoaderError; use Twig\Error\RuntimeError; @@ -71,6 +72,7 @@ class Environment /** @var bool */ private $useYield; private $defaultRuntimeLoader; + private array $hotCache = []; /** * Constructor. @@ -233,6 +235,18 @@ class Environment return $this->strictVariables; } + public function removeCache(string $name): void + { + $cls = $this->getTemplateClass($name); + $this->hotCache[$name] = $cls.'_'.bin2hex(random_bytes(16)); + + if ($this->cache instanceof RemovableCacheInterface) { + $this->cache->remove($name, $cls); + } else { + throw new \LogicException(\sprintf('The "%s" cache class does not support removing template cache as it does not implement the "RemovableCacheInterface" interface.', \get_class($this->cache))); + } + } + /** * Gets the current cache implementation. * @@ -287,7 +301,7 @@ class Environment */ public function getTemplateClass(string $name, ?int $index = null): string { - $key = $this->getLoader()->getCacheKey($name).$this->optionsHash; + $key = ($this->hotCache[$name] ?? $this->getLoader()->getCacheKey($name)).$this->optionsHash; return '__TwigTemplate_'.hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', $key).(null === $index ? '' : '___'.$index); } @@ -379,8 +393,10 @@ class Environment if (!class_exists($cls, false)) { $source = $this->getLoader()->getSourceContext($name); $content = $this->compileSource($source); - $this->cache->write($key, $content); - $this->cache->load($key); + if (!isset($this->hotCache[$name])) { + $this->cache->write($key, $content); + $this->cache->load($key); + } if (!class_exists($mainCls, false)) { /* Last line of defense if either $this->bcWriteCacheFile was used, diff --git a/tests/EnvironmentTest.php b/tests/EnvironmentTest.php index b5100a0aa..7bbc61cef 100644 --- a/tests/EnvironmentTest.php +++ b/tests/EnvironmentTest.php @@ -22,6 +22,7 @@ use Twig\Extension\AbstractExtension; use Twig\Extension\ExtensionInterface; use Twig\Extension\GlobalsInterface; use Twig\Loader\ArrayLoader; +use Twig\Loader\FilesystemLoader; use Twig\Loader\LoaderInterface; use Twig\Node\Node; use Twig\NodeVisitor\NodeVisitorInterface; @@ -497,6 +498,53 @@ EOF $g3 = $twig->getGlobals(); $this->assertNotSame($g3['global_ext'], $g2['global_ext']); } + + public function testHotCache() + { + $dir = sys_get_temp_dir().'/twig-hot-cache-test'; + if (is_dir($dir)) { + FilesystemHelper::removeDir($dir); + } + mkdir($dir); + file_put_contents($dir.'/index.twig', 'x'); + try { + $twig = new Environment(new FilesystemLoader($dir), [ + 'debug' => false, + 'auto_reload' => false, + 'cache' => $dir.'/cache', + ]); + + // prime the cache + $this->assertSame('x', $twig->load('index.twig')->render([])); + + // update the template + file_put_contents($dir.'/index.twig', 'y'); + + // re-render, should use the cached version + $this->assertSame('x', $twig->load('index.twig')->render([])); + + // clear the cache + $twig->removeCache('index.twig'); + + // re-render, should use the updated template + $this->assertSame('y', $twig->load('index.twig')->render([])); + + // the new template should not be cached + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir.'/cache', \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + $count = 0; + foreach ($iterator as $fileInfo) { + if (!$fileInfo->isDir()) { + ++$count; + } + } + $this->assertSame(0, $count); + + // re-render, should use the updated template + $this->assertSame('y', $twig->load('index.twig')->render([])); + } finally { + FilesystemHelper::removeDir($dir); + } + } } class EnvironmentTest_Extension_WithGlobals extends AbstractExtension