diff --git a/core/core.services.yml b/core/core.services.yml index 06981a0326e506709f0db25b4ac94a57e4c4bd51..24fa98eaaaaa638d888a3562ef4fd937643f6c46 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -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 diff --git a/core/lib/Drupal/Core/Block/BlockBase.php b/core/lib/Drupal/Core/Block/BlockBase.php index e0c6a12200fd858db905a6cd0f8d886032b8aa59..8a92e2a0de150f19d8a300aa038d75fc54fea28b 100644 --- a/core/lib/Drupal/Core/Block/BlockBase.php +++ b/core/lib/Drupal/Core/Block/BlockBase.php @@ -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); diff --git a/core/lib/Drupal/Core/Cache/CacheContexts.php b/core/lib/Drupal/Core/Cache/CacheContexts.php index a2d8bdb6ea0f239e45f8aec073efefd1629dba01..7e70915bb6a48d93ede9a960ca8d4f6641e07c4d 100644 --- a/core/lib/Drupal/Core/Cache/CacheContexts.php +++ b/core/lib/Drupal/Core/Cache/CacheContexts.php @@ -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; } } diff --git a/core/lib/Drupal/Core/Cache/CacheableInterface.php b/core/lib/Drupal/Core/Cache/CacheableInterface.php index 4af960ac65025fca77c60231c5b0d8e4906ed831..70b0a8733b71b78fd6382cd9092205ab4ff7189a 100644 --- a/core/lib/Drupal/Core/Cache/CacheableInterface.php +++ b/core/lib/Drupal/Core/Cache/CacheableInterface.php @@ -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 diff --git a/core/lib/Drupal/Core/Cache/CalculatedCacheContextInterface.php b/core/lib/Drupal/Core/Cache/CalculatedCacheContextInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..795a512e72f993a03931573389ed49b0cd323065 --- /dev/null +++ b/core/lib/Drupal/Core/Cache/CalculatedCacheContextInterface.php @@ -0,0 +1,39 @@ +<?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); + +} diff --git a/core/lib/Drupal/Core/Cache/MenuActiveTrailCacheContext.php b/core/lib/Drupal/Core/Cache/MenuActiveTrailCacheContext.php new file mode 100644 index 0000000000000000000000000000000000000000..7c41bee2a8e00922eb4bece18a28e9ce8bdc6daa --- /dev/null +++ b/core/lib/Drupal/Core/Cache/MenuActiveTrailCacheContext.php @@ -0,0 +1,36 @@ +<?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); + } + +} diff --git a/core/lib/Drupal/Core/Menu/MenuActiveTrail.php b/core/lib/Drupal/Core/Menu/MenuActiveTrail.php index 4732c5cb99c4654db128db4863d0c1fd7a239972..6247b29ee13fc749b801bdf11861ddb3b8752e9d 100644 --- a/core/lib/Drupal/Core/Menu/MenuActiveTrail.php +++ b/core/lib/Drupal/Core/Menu/MenuActiveTrail.php @@ -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} */ diff --git a/core/lib/Drupal/Core/Menu/MenuActiveTrailInterface.php b/core/lib/Drupal/Core/Menu/MenuActiveTrailInterface.php index db485c43544a1e396a1b703356b9a5c60880a0d8..49f4e99d5b6337ecd0b417dafca5a52348bb0eef 100644 --- a/core/lib/Drupal/Core/Menu/MenuActiveTrailInterface.php +++ b/core/lib/Drupal/Core/Menu/MenuActiveTrailInterface.php @@ -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. * diff --git a/core/modules/system/src/Plugin/Block/SystemMenuBlock.php b/core/modules/system/src/Plugin/Block/SystemMenuBlock.php index 8ddcc46508ef19970b8b2320e8cc4ddf9d672034..93aefef8ebccad9b3a8cf6e311f2ddae440f2f09 100644 --- a/core/modules/system/src/Plugin/Block/SystemMenuBlock.php +++ b/core/modules/system/src/Plugin/Block/SystemMenuBlock.php @@ -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, + ]; } } diff --git a/core/tests/Drupal/Tests/Core/Cache/CacheContextsTest.php b/core/tests/Drupal/Tests/Core/Cache/CacheContextsTest.php index fff9df226adba13d056e08b8b4eb3bcc0095f152..cd749683833803cab5784dfe970fe6d68c406499 100644 --- a/core/tests/Drupal/Tests/Core/Cache/CacheContextsTest.php +++ b/core/tests/Drupal/Tests/Core/Cache/CacheContextsTest.php @@ -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); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php b/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php index fc99cc3d8c21bf7b906bb1b53da61da183768f67..2ea72ff6ace33f61ccb1ac81c991f2c49cab326a 100644 --- a/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php +++ b/core/tests/Drupal/Tests/Core/Menu/MenuActiveTrailTest.php @@ -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)); } }