Commit dfaf2e6d authored by catch's avatar catch

Issue #2428563 by Wim Leers: Introduce parameter-dependent cache contexts

parent 59d5c098
......@@ -33,6 +33,12 @@ services:
class: Drupal\Core\Cache\TimeZoneCacheContext
tags:
- { name: cache.context}
cache_context.menu.active_trail:
class: Drupal\Core\Cache\MenuActiveTrailCacheContext
calls:
- [setContainer, ['@service_container']]
tags:
- { name: cache.context}
cache_tags.invalidator:
parent: container.trait
class: Drupal\Core\Cache\CacheTagsInvalidator
......
......@@ -9,6 +9,7 @@
use Drupal\block\BlockInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Cache\CacheContexts;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContextAwarePluginBase;
use Drupal\Component\Utility\Unicode;
......@@ -223,8 +224,9 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta
// Remove the required cache contexts from the list of contexts a user can
// choose to modify by: they must always be applied.
$context_labels = array();
foreach ($this->getRequiredCacheContexts() as $context) {
$context_labels[] = $form['cache']['contexts']['#options'][$context];
$all_contexts = \Drupal::service("cache_contexts")->getLabels(TRUE);
foreach (array_keys(CacheContexts::parseTokens($this->getRequiredCacheContexts())) as $context) {
$context_labels[] = $all_contexts[$context];
unset($form['cache']['contexts']['#options'][$context]);
}
$required_context_list = implode(', ', $context_labels);
......
......@@ -8,14 +8,22 @@
namespace Drupal\Core\Cache;
use Drupal\Component\Utility\String;
use Drupal\Core\Database\Query\SelectInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines the CacheContexts service.
* Converts cache context tokens into cache keys.
*
* Converts cache context IDs into their final string values, to be used as
* cache keys.
* Uses cache context services (services tagged with 'cache.context', and whose
* service ID has the 'cache_context.' prefix) to dynamically generate cache
* keys based on the request context, thus allowing developers to express the
* state by which should varied (the current URL, language, and so on).
*
* Note that this maps exactly to HTTP's Vary header semantics:
* @link http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.44
*
* @see \Drupal\Core\Cache\CacheContextInterface
* @see \Drupal\Core\Cache\CalculatedCacheContextInterface
* @see \Drupal\Core\Cache\CacheContextsPass
*/
class CacheContexts {
......@@ -61,64 +69,88 @@ public function getAll() {
*
* To be used in cache configuration forms.
*
* @param bool $include_calculated_cache_contexts
* Whether to also return calculated cache contexts. Default to FALSE.
*
* @return array
* An array of available cache contexts and corresponding labels.
*/
public function getLabels() {
public function getLabels($include_calculated_cache_contexts = FALSE) {
$with_labels = array();
foreach ($this->contexts as $context) {
$with_labels[$context] = $this->getService($context)->getLabel();
$service = $this->getService($context);
if (!$include_calculated_cache_contexts && $service instanceof CalculatedCacheContextInterface) {
continue;
}
$with_labels[$context] = $service->getLabel();
}
return $with_labels;
}
/**
* Converts cache context tokens to string representations of the context.
* Converts cache context tokens to cache keys.
*
* @param string[] $contexts
* An array of cache context IDs.
* A cache context token is either:
* - a cache context ID (if the service ID is 'cache_context.foo', then 'foo'
* is a cache context ID), e.g. 'foo'
* - a calculated cache context ID, followed by a double colon, followed by
* the parameter for the calculated cache context, e.g. 'bar:some_parameter'
*
* @param string[] $context_tokens
* An array of cache context tokens.
*
* @return string[]
* A copy of the input, with cache context tokens converted.
* The array of corresponding cache keys.
*
* @throws \InvalidArgumentException
*/
public function convertTokensToKeys(array $contexts) {
$materialized_contexts = [];
foreach ($contexts as $context) {
if (!in_array($context, $this->contexts)) {
throw new \InvalidArgumentException(String::format('"@context" is not a valid cache context ID.', ['@context' => $context]));
public function convertTokensToKeys(array $context_tokens) {
$keys = [];
foreach (static::parseTokens($context_tokens) as $context_id => $parameter) {
if (!in_array($context_id, $this->contexts)) {
throw new \InvalidArgumentException(String::format('"@context" is not a valid cache context ID.', ['@context' => $context_id]));
}
$materialized_contexts[] = $this->getContext($context);
$keys[] = $this->getService($context_id)->getContext($parameter);
}
return $materialized_contexts;
return $keys;
}
/**
* Provides the string representation of a cache context.
* Retrieves a cache context service from the container.
*
* @param string $context
* A cache context ID of an available cache context service.
* @param string $context_id
* The context ID, which together with the service ID prefix allows the
* corresponding cache context service to be retrieved.
*
* @return string
* The string representation of a cache context.
* @return \Drupal\Core\Cache\CacheContextInterface
* The requested cache context service.
*/
protected function getContext($context) {
return $this->getService($context)->getContext();
protected function getService($context_id) {
return $this->container->get('cache_context.' . $context_id);
}
/**
* Retrieves a cache context service from the container.
* Parses cache context tokens into context IDs and optional parameters.
*
* @param string $context
* The context ID, which together with the service ID prefix allows the
* corresponding cache context service to be retrieved.
* @param string[] $context_tokens
* An array of cache context tokens.
*
* @return \Drupal\Core\Cache\CacheContextInterface
* The requested cache context service.
* @return array
* An array with the parsed results, with the cache context IDs as keys, and
* the associated parameter as value (for a calculated cache context), or
* NULL if there is no parameter.
*/
protected function getService($context) {
return $this->container->get('cache_context.' . $context);
public static function parseTokens(array $context_tokens) {
$contexts_with_parameters = [];
foreach ($context_tokens as $context) {
$context_id = $context;
$parameter = NULL;
if (strpos($context, ':') !== FALSE) {
list($context_id, $parameter) = explode(':', $context, 2);
}
$contexts_with_parameters[$context_id] = $parameter;
}
return $contexts_with_parameters;
}
}
......@@ -25,6 +25,8 @@ interface CacheableInterface {
/**
* The cache keys associated with this potentially cacheable object.
*
* These identify the object.
*
* @return string[]
* An array of strings, used to generate a cache ID.
*/
......@@ -33,6 +35,8 @@ public function getCacheKeys();
/**
* The cache contexts associated with this potentially cacheable object.
*
* These identify a specific variation/representation of the object.
*
* Cache contexts are tokens: placeholders that are converted to cache keys by
* the @cache_contexts service. The replacement value depends on the request
* context (the current URL, language, and so on). They're converted before
......
<?php
/**
* @file
* Contains \Drupal\Core\Cache\CacheContextInterface.
*/
namespace Drupal\Core\Cache;
/**
* Provides an interface for defining a calculated cache context service.
*/
interface CalculatedCacheContextInterface {
/**
* Returns the label of the cache context.
*
* @return string
* The label of the cache context.
*
* @see Cache
*/
public static function getLabel();
/**
* Returns the string representation of the cache context.
*
* A cache context service's name is used as a token (placeholder) cache key,
* and is then replaced with the string returned by this method.
*
* @param string $parameter
* The parameter.
*
* @return string
* The string representation of the cache context.
*/
public function getContext($parameter);
}
<?php
/**
* @file
* Contains \Drupal\Core\Cache\MenuActiveTrailCacheContext.
*/
namespace Drupal\Core\Cache;
use Symfony\Component\DependencyInjection\ContainerAware;
/**
* Defines the MenuActiveTrailCacheContext service.
*
* This class is container-aware to avoid initializing the 'menu.active_trail'
* service (and its dependencies) when it is not necessary.
*/
class MenuActiveTrailCacheContext extends ContainerAware implements CalculatedCacheContextInterface {
/**
* {@inheritdoc}
*/
public static function getLabel() {
return t("Active menu trail");
}
/**
* {@inheritdoc}
*/
public function getContext($menu_name) {
$active_trail = $this->container->get('menu.active_trail')
->getActiveTrailIds($menu_name);
return 'menu_trail.' . $menu_name . '|' . implode('|', $active_trail);
}
}
......@@ -63,13 +63,6 @@ public function getActiveTrailIds($menu_name) {
return $active_trail;
}
/**
* {@inheritdoc}
*/
public function getActiveTrailCacheKey($menu_name) {
return 'menu_trail.' . implode('|', $this->getActiveTrailIds($menu_name));
}
/**
* {@inheritdoc}
*/
......
......@@ -26,17 +26,6 @@ interface MenuActiveTrailInterface {
*/
public function getActiveTrailIds($menu_name);
/**
* Gets the active trail cache key of the specified menu tree.
*
* @param string $menu_name
* The menu name of the requested tree.
*
* @return string
* The cache key that uniquely identifies the active trail of the menu tree.
*/
public function getActiveTrailCacheKey($menu_name);
/**
* Fetches a menu link which matches the route name, parameters and menu name.
*
......
......@@ -180,15 +180,6 @@ public function defaultConfiguration() {
];
}
/**
* {@inheritdoc}
*/
public function getCacheKeys() {
// Add a key for the active menu trail.
$menu = $this->getDerivativeId();
return array_merge(parent::getCacheKeys(), array($this->menuActiveTrail->getActiveTrailCacheKey($menu)));
}
/**
* {@inheritdoc}
*/
......@@ -206,9 +197,12 @@ public function getCacheTags() {
* {@inheritdoc}
*/
protected function getRequiredCacheContexts() {
// Menu blocks must be cached per role: different roles may have access to
// different menu links.
return array('user.roles');
// Menu blocks must be cached per role and per active trail.
$menu_name = $this->getDerivativeId();
return [
'user.roles',
'menu.active_trail:' . $menu_name,
];
}
}
......@@ -9,7 +9,9 @@
use Drupal\Core\Cache\CacheContexts;
use Drupal\Core\Cache\CacheContextInterface;
use Drupal\Core\Cache\CalculatedCacheContextInterface;
use Drupal\Tests\UnitTestCase;
use Symfony\Component\DependencyInjection\Container;
/**
* @coversDefaultClass \Drupal\Core\Cache\CacheContexts
......@@ -17,20 +19,19 @@
*/
class CacheContextsTest extends UnitTestCase {
public function testContextPlaceholdersAreReplaced() {
/**
* @covers ::convertTokensToKeys
*/
public function testConvertTokensToKeys() {
$container = $this->getMockContainer();
$container->expects($this->once())
->method("get")
->with("cache_context.foo")
->will($this->returnValue(new FooCacheContext()));
$cache_contexts = new CacheContexts($container, $this->getContextsFixture());
$new_keys = $cache_contexts->convertTokensToKeys(
['foo']
);
$new_keys = $cache_contexts->convertTokensToKeys([
'foo',
'baz:parameter',
]);
$expected = ['bar'];
$expected = ['bar', 'baz.cnenzrgre'];
$this->assertEquals($expected, $new_keys);
}
......@@ -44,24 +45,41 @@ public function testInvalidContext() {
$container = $this->getMockContainer();
$cache_contexts = new CacheContexts($container, $this->getContextsFixture());
$cache_contexts->convertTokensToKeys(
["non-cache-context"]
);
$cache_contexts->convertTokensToKeys(["non-cache-context"]);
}
/**
* @covers ::convertTokensToKeys
*
* @expectedException \Exception
*
* @dataProvider providerTestInvalidCalculatedContext
*/
public function testInvalidCalculatedContext($context_token) {
$container = $this->getMockContainer();
$cache_contexts = new CacheContexts($container, $this->getContextsFixture());
$cache_contexts->convertTokensToKeys([$context_token]);
}
/**
* Provides a list of invalid 'baz' cache contexts: the parameter is missing.
*/
public function providerTestInvalidCalculatedContext() {
return [
['baz'],
['baz:'],
];
}
public function testAvailableContextStrings() {
$cache_contexts = new CacheContexts($this->getMockContainer(), $this->getContextsFixture());
$contexts = $cache_contexts->getAll();
$this->assertEquals(array("foo"), $contexts);
$this->assertEquals(array("foo", "baz"), $contexts);
}
public function testAvailableContextLabels() {
$container = $this->getMockContainer();
$container->expects($this->once())
->method("get")
->with("cache_context.foo")
->will($this->returnValue(new FooCacheContext()));
$cache_contexts = new CacheContexts($container, $this->getContextsFixture());
$labels = $cache_contexts->getLabels();
$expected = array("foo" => "Foo");
......@@ -69,14 +87,22 @@ public function testAvailableContextLabels() {
}
protected function getContextsFixture() {
return array('foo');
return array('foo', 'baz');
}
protected function getMockContainer() {
return $this->getMockBuilder('Drupal\Core\DependencyInjection\Container')
->disableOriginalConstructor()
->getMock();
$container = $this->getMockBuilder('Drupal\Core\DependencyInjection\Container')
->disableOriginalConstructor()
->getMock();
$container->expects($this->any())
->method('get')
->will($this->returnValueMap([
['cache_context.foo', Container::EXCEPTION_ON_INVALID_REFERENCE, new FooCacheContext()],
['cache_context.baz', Container::EXCEPTION_ON_INVALID_REFERENCE, new BazCacheContext()],
]));
return $container;
}
}
/**
......@@ -100,3 +126,26 @@ public function getContext() {
}
/**
* Fake calculated cache context class.
*/
class BazCacheContext implements CalculatedCacheContextInterface {
/**
* {@inheritdoc}
*/
public static function getLabel() {
return 'Baz';
}
/**
* {@inheritdoc}
*/
public function getContext($parameter) {
if (!is_string($parameter) || strlen($parameter) === 0) {
throw new \Exception();
}
return 'baz.' . str_rot13($parameter);
}
}
......@@ -94,28 +94,25 @@ public function provider() {
$link_1_parent_ids = array('baby_llama_link_1', 'mama_llama_link', '');
$empty_active_trail = array('');
$link_1__active_trail_cache_key = 'menu_trail.baby_llama_link_1|mama_llama_link|';
$empty_active_trail_cache_key = 'menu_trail.';
// No active link is returned when zero links match the current route.
$data[] = array($request, array(), $this->randomMachineName(), NULL, $empty_active_trail, $empty_active_trail_cache_key);
$data[] = array($request, array(), $this->randomMachineName(), NULL, $empty_active_trail);
// The first (and only) matching link is returned when one link matches the
// current route.
$data[] = array($request, array('baby_llama_link_1' => $link_1), $this->randomMachineName(), $link_1, $link_1_parent_ids, $link_1__active_trail_cache_key);
$data[] = array($request, array('baby_llama_link_1' => $link_1), $this->randomMachineName(), $link_1, $link_1_parent_ids);
// The first of multiple matching links is returned when multiple links
// match the current route, where "first" is determined by sorting by key.
$data[] = array($request, array('baby_llama_link_1' => $link_1, 'baby_llama_link_2' => $link_2), $this->randomMachineName(), $link_1, $link_1_parent_ids, $link_1__active_trail_cache_key);
$data[] = array($request, array('baby_llama_link_1' => $link_1, 'baby_llama_link_2' => $link_2), $this->randomMachineName(), $link_1, $link_1_parent_ids);
// No active link is returned in case of a 403.
$request = new Request();
$request->attributes->set('_exception_statuscode', 403);
$data[] = array($request, FALSE, $this->randomMachineName(), NULL, $empty_active_trail, $empty_active_trail_cache_key);
$data[] = array($request, FALSE, $this->randomMachineName(), NULL, $empty_active_trail);
// No active link is returned when the route name is missing.
$request = new Request();
$data[] = array($request, FALSE, $this->randomMachineName(), NULL, $empty_active_trail, $empty_active_trail_cache_key);
$data[] = array($request, FALSE, $this->randomMachineName(), NULL, $empty_active_trail);
return $data;
}
......@@ -144,20 +141,19 @@ public function testGetActiveLink(Request $request, $links, $menu_name, $expecte
* Tests getActiveTrailIds().
*
* @covers ::getActiveTrailIds
* @covers ::getActiveTrailCacheKey
* @dataProvider provider
*/
public function testGetActiveTrailIds(Request $request, $links, $menu_name, $expected_link, $expected_trail, $expected_cache_key) {
public function testGetActiveTrailIds(Request $request, $links, $menu_name, $expected_link, $expected_trail) {
$expected_trail_ids = array_combine($expected_trail, $expected_trail);
$this->requestStack->push($request);
if ($links !== FALSE) {
$this->menuLinkManager->expects($this->exactly(2))
$this->menuLinkManager->expects($this->once())
->method('loadLinksbyRoute')
->with('baby_llama')
->will($this->returnValue($links));
if ($expected_link !== NULL) {
$this->menuLinkManager->expects($this->exactly(2))
$this->menuLinkManager->expects($this->once())
->method('getParentIds')
->will($this->returnValueMap(array(
array($expected_link->getPluginId(), $expected_trail_ids),
......@@ -166,7 +162,6 @@ public function testGetActiveTrailIds(Request $request, $links, $menu_name, $exp
}
$this->assertSame($expected_trail_ids, $this->menuActiveTrail->getActiveTrailIds($menu_name));
$this->assertSame($expected_cache_key, $this->menuActiveTrail->getActiveTrailCacheKey($menu_name));
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment