From 4d036a91480404b04cc36ef28cacdba08eaa92c0 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Wed, 30 Dec 2020 09:13:01 +0100 Subject: [PATCH] Add a cache tag --- .github/workflows/ci.yml | 1 + CHANGELOG | 1 + doc/tags/cache.rst | 112 ++++++++++++++++++ doc/tags/index.rst | 1 + extra/cache-extra/.gitattributes | 2 + extra/cache-extra/.gitignore | 4 + extra/cache-extra/CacheExtension.php | 25 ++++ extra/cache-extra/CacheRuntime.php | 29 +++++ extra/cache-extra/LICENSE | 19 +++ extra/cache-extra/Node/CacheNode.php | 81 +++++++++++++ extra/cache-extra/README.md | 7 ++ extra/cache-extra/Tests/Fixtures/cache.test | 22 ++++ extra/cache-extra/Tests/FunctionalTest.php | 90 ++++++++++++++ extra/cache-extra/Tests/IntegrationTest.php | 46 +++++++ .../TokenParser/CacheTokenParser.php | 69 +++++++++++ extra/cache-extra/composer.json | 36 ++++++ extra/cache-extra/phpunit.xml.dist | 30 +++++ extra/twig-extra-bundle/Extensions.php | 10 ++ extra/twig-extra-bundle/composer.json | 1 + .../src/Resources/config/cache.php | 31 +++++ 20 files changed, 617 insertions(+) create mode 100644 doc/tags/cache.rst create mode 100644 extra/cache-extra/.gitattributes create mode 100644 extra/cache-extra/.gitignore create mode 100644 extra/cache-extra/CacheExtension.php create mode 100644 extra/cache-extra/CacheRuntime.php create mode 100644 extra/cache-extra/LICENSE create mode 100644 extra/cache-extra/Node/CacheNode.php create mode 100644 extra/cache-extra/README.md create mode 100644 extra/cache-extra/Tests/Fixtures/cache.test create mode 100644 extra/cache-extra/Tests/FunctionalTest.php create mode 100644 extra/cache-extra/Tests/IntegrationTest.php create mode 100644 extra/cache-extra/TokenParser/CacheTokenParser.php create mode 100644 extra/cache-extra/composer.json create mode 100644 extra/cache-extra/phpunit.xml.dist create mode 100644 extra/twig-extra-bundle/src/Resources/config/cache.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd2a11f08..62b4b3464 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,7 @@ jobs: - '7.4' - '8.0' extension: + - 'extra/cache-extra' - 'extra/cssinliner-extra' - 'extra/html-extra' - 'extra/inky-extra' diff --git a/CHANGELOG b/CHANGELOG index 6b31d99e3..6da914f9e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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) diff --git a/doc/tags/cache.rst b/doc/tags/cache.rst new file mode 100644 index 000000000..b0356afba --- /dev/null +++ b/doc/tags/cache.rst @@ -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()); + } + } + }); diff --git a/doc/tags/index.rst b/doc/tags/index.rst index 1e28da3d5..b3c104080 100644 --- a/doc/tags/index.rst +++ b/doc/tags/index.rst @@ -7,6 +7,7 @@ Tags apply autoescape block + cache deprecated do embed diff --git a/extra/cache-extra/.gitattributes b/extra/cache-extra/.gitattributes new file mode 100644 index 000000000..aa02dc651 --- /dev/null +++ b/extra/cache-extra/.gitattributes @@ -0,0 +1,2 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore diff --git a/extra/cache-extra/.gitignore b/extra/cache-extra/.gitignore new file mode 100644 index 000000000..76367ee5b --- /dev/null +++ b/extra/cache-extra/.gitignore @@ -0,0 +1,4 @@ +vendor/ +composer.lock +phpunit.xml +.phpunit.result.cache diff --git a/extra/cache-extra/CacheExtension.php b/extra/cache-extra/CacheExtension.php new file mode 100644 index 000000000..5cf849dc6 --- /dev/null +++ b/extra/cache-extra/CacheExtension.php @@ -0,0 +1,25 @@ +cache = $cache; + } + + public function getCache(): CacheInterface + { + return $this->cache; + } +} diff --git a/extra/cache-extra/LICENSE b/extra/cache-extra/LICENSE new file mode 100644 index 000000000..efb17f98e --- /dev/null +++ b/extra/cache-extra/LICENSE @@ -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. diff --git a/extra/cache-extra/Node/CacheNode.php b/extra/cache-extra/Node/CacheNode.php new file mode 100644 index 000000000..9f949c7ef --- /dev/null +++ b/extra/cache-extra/Node/CacheNode.php @@ -0,0 +1,81 @@ + $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") + ; + } +} diff --git a/extra/cache-extra/README.md b/extra/cache-extra/README.md new file mode 100644 index 000000000..8ad17d3c2 --- /dev/null +++ b/extra/cache-extra/README.md @@ -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. diff --git a/extra/cache-extra/Tests/Fixtures/cache.test b/extra/cache-extra/Tests/Fixtures/cache.test new file mode 100644 index 000000000..734f106cb --- /dev/null +++ b/extra/cache-extra/Tests/Fixtures/cache.test @@ -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 diff --git a/extra/cache-extra/Tests/FunctionalTest.php b/extra/cache-extra/Tests/FunctionalTest.php new file mode 100644 index 000000000..13730c07b --- /dev/null +++ b/extra/cache-extra/Tests/FunctionalTest.php @@ -0,0 +1,90 @@ +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; + } +} diff --git a/extra/cache-extra/Tests/IntegrationTest.php b/extra/cache-extra/Tests/IntegrationTest.php new file mode 100644 index 000000000..86e847f59 --- /dev/null +++ b/extra/cache-extra/Tests/IntegrationTest.php @@ -0,0 +1,46 @@ +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'; + } +} diff --git a/extra/cache-extra/composer.json b/extra/cache-extra/composer.json new file mode 100644 index 000000000..d07a40826 --- /dev/null +++ b/extra/cache-extra/composer.json @@ -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" + } + } +} diff --git a/extra/cache-extra/phpunit.xml.dist b/extra/cache-extra/phpunit.xml.dist new file mode 100644 index 000000000..fb5f43192 --- /dev/null +++ b/extra/cache-extra/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + + diff --git a/extra/twig-extra-bundle/Extensions.php b/extra/twig-extra-bundle/Extensions.php index 14cab75a8..e542604e1 100644 --- a/extra/twig-extra-bundle/Extensions.php +++ b/extra/twig-extra-bundle/Extensions.php @@ -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, diff --git a/extra/twig-extra-bundle/composer.json b/extra/twig-extra-bundle/composer.json index 31ef35744..2bc402273 100644 --- a/extra/twig-extra-bundle/composer.json +++ b/extra/twig-extra-bundle/composer.json @@ -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", diff --git a/extra/twig-extra-bundle/src/Resources/config/cache.php b/extra/twig-extra-bundle/src/Resources/config/cache.php new file mode 100644 index 000000000..38f6d7f4c --- /dev/null +++ b/extra/twig-extra-bundle/src/Resources/config/cache.php @@ -0,0 +1,31 @@ +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) + ; +};