Add a cache tag

This commit is contained in:
Fabien Potencier
2020-12-30 09:13:01 +01:00
parent cb88e0a68c
commit 4d036a9148
20 changed files with 617 additions and 0 deletions
+1
View File
@@ -80,6 +80,7 @@ jobs:
- '7.4'
- '8.0'
extension:
- 'extra/cache-extra'
- 'extra/cssinliner-extra'
- 'extra/html-extra'
- 'extra/inky-extra'
+1
View File
@@ -1,5 +1,6 @@
# 3.2.0 (2021-XX-XX)
* Add the Cache extension in the "extra" repositories: "cache" tag
* Add "registerUndefinedTokenParserCallback"
# 3.1.1 (2020-10-27)
+112
View File
@@ -0,0 +1,112 @@
``cache``
=========
.. versionadded:: 3.2
The ``cache`` tag was added in Twig 3.2.
The ``cache`` tag tells Twig to cache a template fragment:
.. code-block:: twig
{% cache "cache key" %}
Cached forever (depending on the cache implementation)
{% endcache %}
If you want to expire the cache after a certain amount of time, specify an
expiration in seconds via the ``ttl()`` modifier:
.. code-block:: twig
{% cache "cache key" ttl(300) %}
Cached for 300 seconds
{% endcache %}
The cache key can be any string that does not use the following reserved
characters ``{}()/\@:``; a good practice is to embed some useful information in
the key that allows the cache to automatically expire when it must be
refreshed:
* Give each cache a unique name and namespace it like your templates;
* Embed an integer that you increment whenever the template code changes (to
automatically invalidate all current caches);
* Embed a unique key that is updated whenever the variables used in the
template code changes.
For instance, I would use ``{% cache "blog_post;v1;" ~ post.id ~ ";" ~
post.updated_at %}`` to cache a blog content template fragment where
``blog_post`` describes the template fragment, ``v1`` represents the first
version of the template code, ``post.id`` represent the id of the blog post,
and ``post.updated_at`` returns a timestamp that represents the time where the
blog post was last modified.
Using such a strategy for naming cache keys allows to avoid using a ``ttl``.
It's like using a "validation" strategy instead of an "expiration" strategy as
we do for HTTP caches.
If your cache implementation supports tags, you can also tag your cache items:
.. code-block:: twig
{% cache "cache key" tag('blog') %}
Some code
{% endcache %}
{% cache "cache key" tag(['cms', 'blog']) %}
Some code
{% endcache %}
The ``cache`` tag creates a new "scope" for variables, meaning that the changes
are local to the template fragment:
.. code-block:: twig
{% set count = 1 %}
{% cache "cache key" tag('blog') %}
{# Won't affect the value of count outside of the cache tag #}
{% set count = 2 %}
Some code
{% endcache %}
{# Displays 1 #}
{{ count }}
.. note::
The ``cache`` tag is part of the ``CacheExtension`` which is not installed
by default. Install it first:
.. code-block:: bash
$ composer require twig/cache-extra
On Symfony projects, you can automatically enable it by installing the
``twig/extra-bundle``:
.. code-block:: bash
$ composer require twig/extra-bundle
Or add the extension explicitly on the Twig environment::
use Twig\Extra\Cache\CacheExtension;
$twig = new \Twig\Environment(...);
$twig->addExtension(new CacheExtension());
If you are not using Symfony, you must also register the extension runtime::
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Twig\Extra\Cache\CacheRuntime;
use Twig\RuntimeLoader\RuntimeLoaderInterface;
$twig->addRuntimeLoader(new class implements RuntimeLoaderInterface {
public function load($class) {
if (CacheRuntime::class === $class) {
return new CacheRuntime(new FilesystemAdapter());
}
}
});
+1
View File
@@ -7,6 +7,7 @@ Tags
apply
autoescape
block
cache
deprecated
do
embed
+2
View File
@@ -0,0 +1,2 @@
/Tests export-ignore
/phpunit.xml.dist export-ignore
+4
View File
@@ -0,0 +1,4 @@
vendor/
composer.lock
phpunit.xml
.phpunit.result.cache
+25
View File
@@ -0,0 +1,25 @@
<?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\Extra\Cache;
use Twig\Extension\AbstractExtension;
use Twig\Extra\Cache\TokenParser\CacheTokenParser;
final class CacheExtension extends AbstractExtension
{
public function getTokenParsers()
{
return [
new CacheTokenParser(),
];
}
}
+29
View File
@@ -0,0 +1,29 @@
<?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\Extra\Cache;
use Symfony\Contracts\Cache\CacheInterface;
class CacheRuntime
{
private $cache;
public function __construct(CacheInterface $cache)
{
$this->cache = $cache;
}
public function getCache(): CacheInterface
{
return $this->cache;
}
}
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2021 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+81
View File
@@ -0,0 +1,81 @@
<?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\Extra\Cache\Node;
use Twig\Compiler;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Node;
class CacheNode extends Node
{
public function __construct(AbstractExpression $key, ?AbstractExpression $ttl, ?AbstractExpression $tags, Node $body, int $lineno, string $tag)
{
$nodes = ['key' => $key, 'body' => $body];
if (null !== $ttl) {
$nodes['ttl'] = $ttl;
}
if (null !== $tags) {
$nodes['tags'] = $tags;
}
parent::__construct($nodes, [], $lineno, $tag);
}
public function compile(Compiler $compiler): void
{
$compiler
->addDebugInfo($this)
->write('$cached = $this->env->getRuntime(\'Twig\Extra\Cache\CacheRuntime\')->getCache()->get(')
->subcompile($this->getNode('key'))
->raw(", function (\Symfony\Contracts\Cache\ItemInterface \$item) use (\$context) {\n")
->indent()
->write("try {\n")
->indent()
->write("\$item->tag('twig');\n")
;
if ($this->hasNode('tags')) {
$compiler
->write("\$item->tag(")
->subcompile($this->getNode('tags'))
->raw(");\n")
;
}
$compiler
->outdent()
->write("} catch (\Psr\Cache\CacheException \$e) {\n")
->indent()
->write("// cache doesn't support tags\n")
->outdent()
->write("}\n")
;
if ($this->hasNode('ttl')) {
$compiler
->write('$item->expiresAfter(')
->subcompile($this->getNode('ttl'))
->raw(");\n")
;
}
$compiler
->write("ob_start(function () { return ''; });\n")
->subcompile($this->getNode('body'))
->write("\n")
->write("return ob_get_clean();\n")
->outdent()
->write("});\n")
->write("echo '' === \$cached ? '' : new Markup(\$cached, \$this->env->getCharset());\n")
;
}
}
+7
View File
@@ -0,0 +1,7 @@
Cache Extension
===============
This package is a Twig extension that provides integration with the Symfony
Cache component.
It provides a single `cache` tag that allows to cache template fragments.
@@ -0,0 +1,22 @@
--TEST--
"cache" tag
--TEMPLATE--
{% set foo = "bar" %}
{% set value1 %}
{% cache "test;v1" ttl(3) %}
{% set foo = "bar1" %}
{{ random(1, 1000000) }}
{% endcache %}
{% endset %}
{% set value2 %}
{% cache "test;v1" ttl(3) %}
{{ random(1, 1000000) }}
{% endcache %}
{% endset %}
{{ value1 == value2 ? 'OK' : 'KO' }}
{{ foo == "bar" ? 'OK' : 'KO' }}
--DATA--
return []
--EXPECT--
OK
OK
@@ -0,0 +1,90 @@
<?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\Extra\Cache\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Contracts\Cache\CacheInterface;
use Twig\Environment;
use Twig\Error\SyntaxError;
use Twig\Extra\Cache\CacheExtension;
use Twig\Extra\Cache\CacheRuntime;
use Twig\Loader\ArrayLoader;
use Twig\RuntimeLoader\RuntimeLoaderInterface;
class FunctionalTest extends TestCase
{
public function testIsCached()
{
$cache = new ArrayAdapter();
$twig = $this->createEnvironment(['index' => '{% cache "city;v1" %}{{- city -}}{% endcache %}'], $cache);
$this->assertSame('Paris', $twig->render('index', ['city' => 'Paris']));
$value = $cache->get('city;v1', function () { throw new \RuntimeException('Key should be in the cache'); });
$this->assertSame('Paris', $value);
}
public function testTtlNoArgs()
{
$twig = $this->createEnvironment(['index' => '{% cache "ttl_no_args" ttl() %}{% endcache %}']);
$this->expectException(SyntaxError::class);
$this->expectExceptionMessage('The "ttl" modifier takes exactly one argument (0 given) in "index" at line 1.');
$twig->render('index');
}
public function testTtlTooManyArgs()
{
$twig = $this->createEnvironment(['index' => '{% cache "ttl_too_many_args" ttl(0, 1) %}{% endcache %}']);
$this->expectException(SyntaxError::class);
$this->expectExceptionMessage('The "ttl" modifier takes exactly one argument (2 given) in "index" at line 1.');
$twig->render('index');
}
public function testTagsNoArgs()
{
$twig = $this->createEnvironment(['index' => '{% cache "tags_no_args" tags() %}{% endcache %}']);
$this->expectException(SyntaxError::class);
$this->expectExceptionMessage('The "ttl" modifier takes exactly one argument (0 given) in "index" at line 1.');
$twig->render('index');
}
public function testTagsTooManyArgs()
{
$twig = $this->createEnvironment(['index' => '{% cache "tags_too_many_args" tags(["foo"], 1) %}{% endcache %}']);
$this->expectException(SyntaxError::class);
$this->expectExceptionMessage('The "ttl" modifier takes exactly one argument (2 given) in "index" at line 1.');
$twig->render('index');
}
private function createEnvironment(array $templates, ArrayAdapter $cache = null): Environment
{
$twig = new Environment(new ArrayLoader($templates));
$cache = $cache ?? new ArrayAdapter();
$twig->addExtension(new CacheExtension());
$twig->addRuntimeLoader(new class($cache) implements RuntimeLoaderInterface {
private $cache;
public function __construct(CacheInterface $cache)
{
$this->cache = $cache;
}
public function load($class) {
if (CacheRuntime::class === $class) {
return new CacheRuntime($this->cache);
}
}
});
return $twig;
}
}
@@ -0,0 +1,46 @@
<?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\Extra\Cache\Tests;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Twig\Extra\Cache\CacheExtension;
use Twig\Extra\Cache\CacheRuntime;
use Twig\RuntimeLoader\RuntimeLoaderInterface;
use Twig\Test\IntegrationTestCase;
class IntegrationTest extends IntegrationTestCase
{
public function getExtensions()
{
return [
new CacheExtension(),
];
}
protected function getRuntimeLoaders()
{
return [
new class implements RuntimeLoaderInterface {
public function load($class) {
if (CacheRuntime::class === $class) {
return new CacheRuntime(new ArrayAdapter());
}
}
},
];
}
public function getFixturesDir()
{
return __DIR__.'/Fixtures/';
}
}
@@ -0,0 +1,69 @@
<?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\Extra\Cache\TokenParser;
use Twig\Error\SyntaxError;
use Twig\Extra\Cache\Node\CacheNode;
use Twig\Node\Node;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;
class CacheTokenParser extends AbstractTokenParser
{
public function parse(Token $token): Node
{
$stream = $this->parser->getStream();
$expressionParser = $this->parser->getExpressionParser();
$key = $expressionParser->parseExpression();
$ttl = null;
$tags = null;
while ($stream->test(Token::NAME_TYPE)) {
$k = $stream->getCurrent()->getValue();
$stream->next();
$args = $expressionParser->parseArguments();
switch ($k) {
case 'ttl':
if (1 !== count($args)) {
throw new SyntaxError(sprintf('The "ttl" modifier takes exactly one argument (%d given).', count($args)), $stream->getCurrent()->getLine(), $stream->getSourceContext());
}
$ttl = $args->getNode(0);
break;
case 'tags':
if (1 !== count($args)) {
throw new SyntaxError(sprintf('The "ttl" modifier takes exactly one argument (%d given).', count($args)), $stream->getCurrent()->getLine(), $stream->getSourceContext());
}
$tags = $args->getNode(0);
break;
default:
throw new SyntaxError(sprintf('Unknown "%s" configuration.', $k), $stream->getCurrent()->getLine(), $stream->getSourceContext());
}
}
$stream->expect(Token::BLOCK_END_TYPE);
$body = $this->parser->subparse([$this, 'decideCacheEnd'], true);
$stream->expect(Token::BLOCK_END_TYPE);
return new CacheNode($key, $ttl, $tags, $body, $token->getLine(), $this->getTag());
}
public function decideCacheEnd(Token $token): bool
{
return $token->test('endcache');
}
public function getTag(): string
{
return 'cache';
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "twig/cache-extra",
"type": "library",
"description": "A Twig extension for Symfony Cache",
"keywords": ["twig", "html", "cache"],
"homepage": "https://twig.symfony.com",
"license": "MIT",
"minimum-stability": "dev",
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com",
"homepage": "http://fabien.potencier.org",
"role": "Lead Developer"
}
],
"require": {
"php": ">=7.2.5",
"symfony/cache": "^5.0",
"twig/twig": "^2.4|^3.0"
},
"require-dev": {
"symfony/phpunit-bridge": "^4.4.9|^5.0.9"
},
"autoload": {
"psr-4" : { "Twig\\Extra\\Cache\\" : "" },
"exclude-from-classmap": [
"/Tests/"
]
},
"extra": {
"branch-alias": {
"dev-master": "3.2-dev"
}
}
}
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/5.2/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="vendor/autoload.php"
failOnRisky="true"
failOnWarning="true"
>
<php>
<ini name="error_reporting" value="-1" />
</php>
<testsuites>
<testsuite name="Twig Cache Extension Test Suite">
<directory>./Tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory>./</directory>
<exclude>
<directory>./Tests</directory>
<directory>./vendor</directory>
</exclude>
</whitelist>
</filter>
</phpunit>
+10
View File
@@ -11,6 +11,7 @@
namespace Twig\Extra\TwigExtraBundle;
use Twig\Extra\Cache\CacheExtension;
use Twig\Extra\CssInliner\CssInlinerExtension;
use Twig\Extra\Html\HtmlExtension;
use Twig\Extra\Inky\InkyExtension;
@@ -21,6 +22,15 @@ use Twig\Extra\String\StringExtension;
final class Extensions
{
private const EXTENSIONS = [
'cache' => [
'name' => 'cache',
'class' => CacheExtension::class,
'class_name' => 'CacheExtension',
'package' => 'twig/cache-extra',
'filters' => [],
'functions' => [],
'tags' => ['cache'],
],
'html' => [
'name' => 'html',
'class' => HtmlExtension::class,
+1
View File
@@ -22,6 +22,7 @@
},
"require-dev": {
"symfony/phpunit-bridge": "^4.4.9|^5.0.9",
"twig/cache-extra": "^3.0",
"twig/cssinliner-extra": "^2.12|^3.0",
"twig/html-extra": "^2.12|^3.0",
"twig/inky-extra": "^2.12|^3.0",
@@ -0,0 +1,31 @@
<?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 Symfony\Component\DependencyInjection\Loader\Configurator;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
use Twig\Extra\Cache\CacheExtension;
use Twig\Extra\Cache\CacheRuntime;
return static function (ContainerConfigurator $container) {
$container->services()
->set('twig.extension.cache', CacheExtension::class)
->tag('twig.extension')
->set('twig.runtime.cache', CacheRuntime::class)
->args([
service('twig.cache.default'),
])
->tag('twig.runtime')
->alias('twig.cache.default', TagAwareCacheInterface::class)
;
};