diff --git a/core/core.services.yml b/core/core.services.yml index 55d8d2ebc9843e32969d54c5a341bd9242a96d86..eb6a576d257e90205727f2caa45724f99302f1af 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -205,6 +205,7 @@ services: - { name: cache.context} cache_context.timezone: class: Drupal\Core\Cache\Context\TimeZoneCacheContext + arguments: ['@config.factory'] tags: - { name: cache.context} diff --git a/core/lib/Drupal/Core/Cache/Context/CacheContextOptimizableInterface.php b/core/lib/Drupal/Core/Cache/Context/CacheContextOptimizableInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..f7abf0944448192341761841aed2b432230f538e --- /dev/null +++ b/core/lib/Drupal/Core/Cache/Context/CacheContextOptimizableInterface.php @@ -0,0 +1,39 @@ +<?php + +namespace Drupal\Core\Cache\Context; + +/** + * Allows cache contexts to influence how cache contexts are optimized. + */ +interface CacheContextOptimizableInterface { + + /** + * Returns whether a cache context value has variations. + * + * For example, the timezone cache context can define itself as global + * if a site does not have configurable timezones and the language contexts + * are global on a site that is not multilingual. + * + * @return bool + * TRUE if the context has more than one possible variations. + */ + public function hasVariations(): bool; + + /** + * Returns parent contexts for this context. + * + * By default, parents are derived based on the cache context name, user is + * a parent of user.permissions. This allows e.g. the user.roles context to + * expand on this and indicate that user.permissions is also a valid + * parent/replacement. + * + * Note that if implementing this, it is necessary to also include parents of + * the parent contexts, e.g. user.roles must provide both user.permissions and + * user. + * + * @return array + * A list of parents cache contexts. + */ + public function getParentContexts(): array; + +} diff --git a/core/lib/Drupal/Core/Cache/Context/CacheContextsManager.php b/core/lib/Drupal/Core/Cache/Context/CacheContextsManager.php index 91d1b3ab9e993acb105af695ba94b3fc364fa793..7a7b9979882acd63af0b3ed3d8bab056adc837d0 100644 --- a/core/lib/Drupal/Core/Cache/Context/CacheContextsManager.php +++ b/core/lib/Drupal/Core/Cache/Context/CacheContextsManager.php @@ -172,6 +172,22 @@ public function optimizeTokens(array $context_tokens) { [$context_id, $parameter] = explode(':', $context_token); } + // Cache contexts can explicitly provide a list of parents or indicate + // that they are global. + // @todo Also support to optimize them away if a parent of those is + // provided. + $service = $this->getService($context_id); + if ($service instanceof CacheContextOptimizableInterface) { + if (!$service->hasVariations()) { + continue; + } + + if (array_intersect($context_tokens, $service->getParentContexts())) { + // If there is at least one parent context available, skip this one. + continue; + } + } + // Context tokens without: // - a period means they don't have a parent // - a colon means they're not a specific value of a cache context @@ -181,7 +197,7 @@ public function optimizeTokens(array $context_tokens) { } // Check cacheability. If the context defines a max-age of 0, then it // can not be optimized away. Pass the parameter along if we have one. - elseif ($this->getService($context_id)->getCacheableMetadata($parameter)->getCacheMaxAge() === 0) { + elseif ($service->getCacheableMetadata($parameter)->getCacheMaxAge() === 0) { $optimized_content_tokens[] = $context_token; } // The context token has a period or a colon. Iterate over all ancestor diff --git a/core/lib/Drupal/Core/Cache/Context/TimeZoneCacheContext.php b/core/lib/Drupal/Core/Cache/Context/TimeZoneCacheContext.php index 318817264cc7ecaf0704798717799c280c8359fa..b24fc09e326074248c6ce143825ba88101f666c4 100644 --- a/core/lib/Drupal/Core/Cache/Context/TimeZoneCacheContext.php +++ b/core/lib/Drupal/Core/Cache/Context/TimeZoneCacheContext.php @@ -3,6 +3,7 @@ namespace Drupal\Core\Cache\Context; use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Config\ConfigFactoryInterface; /** * Defines the TimeZoneCacheContext service, for "per time zone" caching. @@ -11,7 +12,24 @@ * * @see \Drupal\Core\Session\AccountProxy::setAccount() */ -class TimeZoneCacheContext implements CacheContextInterface { +class TimeZoneCacheContext implements CacheContextInterface, CacheContextOptimizableInterface { + + /** + * The config factory. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $configFactory; + + /** + * Constructor for the TimeZoneCacheContext object. + * + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory. + */ + public function __construct(ConfigFactoryInterface $configFactory) { + $this->configFactory = $configFactory; + } /** * {@inheritdoc} @@ -33,7 +51,25 @@ public function getContext() { * {@inheritdoc} */ public function getCacheableMetadata() { - return new CacheableMetadata(); + $cacheability_metadata = new CacheableMetadata(); + $cacheability_metadata->addCacheableDependency($this->configFactory->get('system.date')); + return $cacheability_metadata; + } + + /** + * {@inheritdoc} + */ + public function hasVariations(): bool { + // The timezone context can not have different values if the site does not + // use configurable timezones. + return (bool) $this->configFactory->get('system.date')->get('timezone.user.configurable'); + } + + /** + * {@inheritdoc} + */ + public function getParentContexts(): array { + return []; } } diff --git a/core/modules/node/tests/src/Functional/NodeCacheTagsNoTimezoneTest.php b/core/modules/node/tests/src/Functional/NodeCacheTagsNoTimezoneTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c92a32c0fddcd4701491403487acfbb17f1042fa --- /dev/null +++ b/core/modules/node/tests/src/Functional/NodeCacheTagsNoTimezoneTest.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\node\Functional; + +use Drupal\Core\Entity\EntityInterface; + +/** + * Tests the Node entity's cache tags. + * + * @group node + */ +class NodeCacheTagsNoTimezoneTest extends NodeCacheTagsTest { + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // Disable configurable timezones, which should result in the timezone cache + // context *not* being added. + $this->config('system.date') + ->set('timezone.user.configurable', FALSE) + ->save(); + } + + /** + * {@inheritdoc} + */ + protected function getAdditionalCacheContextsForEntity(EntityInterface $entity): array { + return []; + } + + /** + * {@inheritdoc} + * + * Each node must have an author. + */ + protected function getAdditionalCacheTagsForEntity(EntityInterface $node): array { + // Because timezone is optimized away, the additional system.date cache tag + // is added. + return ['user:' . $node->getOwnerId(), 'user_view', 'config:system.date']; + } + +} diff --git a/core/tests/Drupal/Tests/Core/Cache/Context/CacheContextsManagerTest.php b/core/tests/Drupal/Tests/Core/Cache/Context/CacheContextsManagerTest.php index f76e8cd647437b76f5439c538852311260360b5e..178cde88b7c55b21ac235c35364c9db7bf8d3720 100644 --- a/core/tests/Drupal/Tests/Core/Cache/Context/CacheContextsManagerTest.php +++ b/core/tests/Drupal/Tests/Core/Cache/Context/CacheContextsManagerTest.php @@ -5,6 +5,7 @@ namespace Drupal\Tests\Core\Cache\Context; use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Cache\Context\CacheContextOptimizableInterface; use Drupal\Core\Cache\Context\CacheContextsManager; use Drupal\Core\Cache\Context\CacheContextInterface; use Drupal\Core\Cache\Context\CalculatedCacheContextInterface; @@ -57,6 +58,16 @@ public function testOptimizeTokens(array $context_tokens, array $optimized_conte Container::EXCEPTION_ON_INVALID_REFERENCE, new NoOptimizeCacheContext(), ], + [ + 'cache_context.x.non-standard', + Container::EXCEPTION_ON_INVALID_REFERENCE, + new NonStandardOptimizeCacheContext(), + ], + [ + 'cache_context.x.global', + Container::EXCEPTION_ON_INVALID_REFERENCE, + new GlobalOptimizeCacheContext(), + ], ]); $cache_contexts_manager = new CacheContextsManager($container, $this->getContextsFixture()); @@ -96,6 +107,24 @@ public static function providerTestOptimizeTokens() { [['a.b.c:foo', 'a'], ['a']], [['a.b.c:foo', 'a.b.c'], ['a.b.c']], + // Non-standard parents. + [['a.b', 'x.non-standard'], ['a.b']], + // Calculated cache contexts are supported as well but there is no + // argument granularity. + [['a.b', 'x.non-standard:argument'], ['a.b']], + [['a', 'a.b', 'x.non-standard'], ['a']], + // Should contexts with explicit parents still respect the default logic? + // Not doing that would allow to deprecate the somewhat strange max-age 0 + // check in favor of returning an empty list? + [['x', 'x.non-standard'], ['x']], + [['a.b.c', 'x.non-standard'], ['a.b.c', 'x.non-standard']], + // @todo Support this as well? + // [['a', 'non-standard'], ['a']], + + // Contexts that return TRUE in isGlobal() are always optimized away. + [['a', 'x.global'], ['a']], + [['x.global'], []], + // max-age 0 is treated as non-optimizable. [['a.b.no-optimize', 'a.b', 'a'], ['a.b.no-optimize', 'a']], ]; @@ -333,3 +362,87 @@ public function getCacheableMetadata() { } } + +/** + * Optimizable context class with a non-default parent. + */ +class NonStandardOptimizeCacheContext implements CacheContextInterface, CacheContextOptimizableInterface { + + /** + * {@inheritdoc} + */ + public static function getLabel() { + return 'Non-Standard'; + } + + /** + * {@inheritdoc} + */ + public function getContext() { + return 'non_standard'; + } + + /** + * {@inheritdoc} + */ + public function getCacheableMetadata($parameter = NULL) { + return new CacheableMetadata(); + } + + /** + * {@inheritdoc} + */ + public function hasVariations(): bool { + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function getParentContexts(): array { + return ['a.b']; + } + +} + +/** + * A global cache context that can always be optimized away. + */ +class GlobalOptimizeCacheContext implements CacheContextInterface, CacheContextOptimizableInterface { + + /** + * {@inheritdoc} + */ + public static function getLabel() { + return 'Non-Standard'; + } + + /** + * {@inheritdoc} + */ + public function getContext() { + return 'non_standard'; + } + + /** + * {@inheritdoc} + */ + public function getCacheableMetadata($parameter = NULL) { + return new CacheableMetadata(); + } + + /** + * {@inheritdoc} + */ + public function hasVariations(): bool { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function getParentContexts(): array { + return []; + } + +}