Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 10 additions & 1 deletion src/Cache/ChainCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
*
* @author Quentin Devos <quentin@devos.pm>
*/
final class ChainCache implements CacheInterface
final class ChainCache implements CacheInterface, RemovableCacheInterface
{
/**
* @param iterable<CacheInterface> $caches The ordered list of caches used to store and fetch cached items
Expand Down Expand Up @@ -69,6 +69,15 @@ public function getTimestamp(string $key): int
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[]
*/
Expand Down
10 changes: 9 additions & 1 deletion src/Cache/FilesystemCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
*
* @author Andrew Tch <andrew@noop.lv>
*/
class FilesystemCache implements CacheInterface
class FilesystemCache implements CacheInterface, RemovableCacheInterface
{
public const FORCE_BYTECODE_INVALIDATION = 1;

Expand Down Expand Up @@ -76,6 +76,14 @@ public function write(string $key, string $content): void
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)) {
Expand Down
6 changes: 5 additions & 1 deletion src/Cache/NullCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class NullCache implements CacheInterface
final class NullCache implements CacheInterface, RemovableCacheInterface
{
public function generateKey(string $name, string $className): string
{
Expand All @@ -35,4 +35,8 @@ public function getTimestamp(string $key): int
{
return 0;
}

public function remove(string $name, string $cls): void
{
}
}
20 changes: 20 additions & 0 deletions src/Cache/RemovableCacheInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Twig\Cache;

/**
* @author Fabien Potencier <fabien@symfony.com>
*/
interface RemovableCacheInterface
{
public function remove(string $name, string $cls): void;
}
22 changes: 19 additions & 3 deletions src/Environment.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
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;
Expand Down Expand Up @@ -71,6 +72,7 @@ class Environment
/** @var bool */
private $useYield;
private $defaultRuntimeLoader;
private array $hotCache = [];

/**
* Constructor.
Expand Down Expand Up @@ -233,6 +235,18 @@ public function isStrictVariables()
return $this->strictVariables;
}

public function removeCache(string $name): void
{
$cls = $this->getTemplateClass($name);
$this->hotCache[$name] = $cls.'_'.bin2hex(random_bytes(16));
Comment thread
fabpot marked this conversation as resolved.

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.
*
Expand Down Expand Up @@ -287,7 +301,7 @@ public function setCache($cache)
*/
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);
}
Expand Down Expand Up @@ -379,8 +393,10 @@ public function loadTemplate(string $cls, string $name, ?int $index = null): Tem
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])) {
Comment thread
fabpot marked this conversation as resolved.
$this->cache->write($key, $content);
$this->cache->load($key);
}

if (!class_exists($mainCls, false)) {
/* Last line of defense if either $this->bcWriteCacheFile was used,
Expand Down
48 changes: 48 additions & 0 deletions tests/EnvironmentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
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;
Expand Down Expand Up @@ -497,6 +498,53 @@ public function getGlobals(): array
$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
Expand Down