Skip to content

Commit

Permalink
feature #4513 Add getLastModified() to extensions (GromNaN)
Browse files Browse the repository at this point in the history
This PR was merged into the 3.x branch.

Discussion
----------

Add `getLastModified()` to extensions

Give to extensions the ability to set a last modification date for cache invalidation.

### Runtime

Currently, the cache is not invalidated when the signature of a runtime method is modified. This is an issue for templates that use named arguments, as argument names have an impact on the generated class.

With this change, extensions using runtime classes can compute a modification date by including the files on which they depend.

By default, the `AbstractExtension` checks if there is a file for the runtime class with the same name of the
This is the convention applied in [Symfony](https://github.com/symfony/symfony/tree/7.3/src/Symfony/Bridge/Twig/Extension) and Twig Extra: `MarkdownExtension` has `MarkdownRuntime`.

### Attribute

Contributing to #3916.

The extension class that will get the configuration from attributes will be able to track the classes having attributes to find the last modification date of all this classes.

### ~BC break~

~In Twig 4.0, the method `getLastModified` will be added to `ExtensionInterface`. It is extremely rare to implement this interface without extending `AbstractExtension`. So adding this method to the interface shouldn't be a problem as the base class has an implementation.~

Commits
-------

d8fe3bd Add `LastModifiedExtensionInterface` and implementation in `AbstractExtension` to track modification of runtime classes
  • Loading branch information
fabpot committed Jan 3, 2025
2 parents 053572e + d8fe3bd commit 5dd37d8
Show file tree
Hide file tree
Showing 8 changed files with 83 additions and 11 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 3.19.0 (2025-XX-XX)

* n/a
* Add `LastModifiedExtensionInterface` and implementation in `AbstractExtension` to track modification of runtime classes

# 3.18.0 (2024-12-29)

Expand Down
18 changes: 13 additions & 5 deletions doc/advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -868,7 +868,7 @@ must be autoload-able)::
// implement the logic to create an instance of $class
// and inject its dependencies
// most of the time, it means using your dependency injection container
if ('CustomRuntimeExtension' === $class) {
if ('CustomTwigRuntime' === $class) {
return new $class(new Rot13Provider());
} else {
// ...
Expand All @@ -884,9 +884,9 @@ must be autoload-able)::
(``\Twig\RuntimeLoader\ContainerRuntimeLoader``).

It is now possible to move the runtime logic to a new
``CustomRuntimeExtension`` class and use it directly in the extension::
``CustomTwigRuntime`` class and use it directly in the extension::

class CustomRuntimeExtension
class CustomTwigRuntime
{
private $rot13Provider;

Expand All @@ -906,13 +906,21 @@ It is now possible to move the runtime logic to a new
public function getFunctions()
{
return [
new \Twig\TwigFunction('rot13', ['CustomRuntimeExtension', 'rot13']),
new \Twig\TwigFunction('rot13', ['CustomTwigRuntime', 'rot13']),
// or
new \Twig\TwigFunction('rot13', 'CustomRuntimeExtension::rot13'),
new \Twig\TwigFunction('rot13', 'CustomTwigRuntime::rot13'),
];
}
}

.. note::

The extension class should implement the ``Twig\Extension\LastModifiedExtensionInterface``
interface to invalidate the template cache when the runtime class is modified.
The ``AbstractExtension`` class implements this interface and tracks the
runtime class if its name is the same as the extension class but ends with
``Runtime`` instead of ``Extension``.

Testing an Extension
--------------------

Expand Down
19 changes: 18 additions & 1 deletion src/Extension/AbstractExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

namespace Twig\Extension;

abstract class AbstractExtension implements ExtensionInterface
abstract class AbstractExtension implements LastModifiedExtensionInterface
{
public function getTokenParsers()
{
Expand Down Expand Up @@ -42,4 +42,21 @@ public function getOperators()
{
return [[], []];
}

public function getLastModified(): int
{
$filename = (new \ReflectionClass($this))->getFileName();
if (!is_file($filename)) {
return 0;
}

$lastModified = filemtime($filename);

// Track modifications of the runtime class if it exists and follows the naming convention
if (str_ends_with($filename, 'Extension.php') && is_file($filename = substr($filename, 0, -13) . 'Runtime.php')) {
$lastModified = max($lastModified, filemtime($filename));
}

return $lastModified;
}
}
8 changes: 8 additions & 0 deletions src/Extension/EscaperExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ public function getFilters(): array
];
}

public function getLastModified(): int
{
return max(
parent::getLastModified(),
filemtime((new \ReflectionClass(EscaperRuntime::class))->getFileName()),
);
}

/**
* @deprecated since Twig 3.10
*/
Expand Down
23 changes: 23 additions & 0 deletions src/Extension/LastModifiedExtensionInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?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\Extension;

interface LastModifiedExtensionInterface extends ExtensionInterface
{
/**
* Returns the last modification time of the extension for cache invalidation.
*
* This timestamp should be the last time the source code of the extension class
* and all its dependencies were modified (including the Runtime class).
*/
public function getLastModified(): int;
}
14 changes: 10 additions & 4 deletions src/ExtensionSet.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Twig\Error\RuntimeError;
use Twig\Extension\ExtensionInterface;
use Twig\Extension\GlobalsInterface;
use Twig\Extension\LastModifiedExtensionInterface;
use Twig\Extension\StagingExtension;
use Twig\Node\Expression\Binary\AbstractBinary;
use Twig\Node\Expression\Unary\AbstractUnary;
Expand Down Expand Up @@ -116,14 +117,19 @@ public function getLastModified(): int
return $this->lastModified;
}

$lastModified = 0;
foreach ($this->extensions as $extension) {
$r = new \ReflectionObject($extension);
if (is_file($r->getFileName()) && ($extensionTime = filemtime($r->getFileName())) > $this->lastModified) {
$this->lastModified = $extensionTime;
if ($extension instanceof LastModifiedExtensionInterface) {
$lastModified = max($extension->getLastModified(), $lastModified);
} else {
$r = new \ReflectionObject($extension);
if (is_file($r->getFileName())) {
$lastModified = max(filemtime($r->getFileName()), $lastModified);
}
}
}

return $this->lastModified;
return $this->lastModified = $lastModified;
}

public function addExtension(ExtensionInterface $extension): void
Expand Down
5 changes: 5 additions & 0 deletions tests/Extension/CoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,11 @@ public function testSandboxedIncludeWithPreloadedTemplate()
$this->expectException(SecurityError::class);
$twig->render('index');
}

public function testLastModified()
{
$this->assertGreaterThan(1000000000, (new CoreExtension())->getLastModified());
}
}

final class CoreTestIteratorAggregate implements \IteratorAggregate
Expand Down
5 changes: 5 additions & 0 deletions tests/Extension/EscaperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ public function testCustomEscapersOnMultipleEnvs()
$this->assertSame('foo**ISO-8859-1**UTF-8', $env1->getRuntime(EscaperRuntime::class)->escape('foo', 'foo', 'ISO-8859-1'));
$this->assertSame('foo**ISO-8859-1**UTF-8**again', $env2->getRuntime(EscaperRuntime::class)->escape('foo', 'foo', 'ISO-8859-1'));
}

public function testLastModified()
{
$this->assertGreaterThan(1000000000, (new EscaperExtension())->getLastModified());
}
}

function legacy_escaper(Environment $twig, $string, $charset)
Expand Down

0 comments on commit 5dd37d8

Please sign in to comment.