Commit 9309d3d9 authored by catch's avatar catch

Issue #2381217 by Wim Leers, dawehner, Fabianx: Views should set cache tags on...

Issue #2381217 by Wim Leers, dawehner, Fabianx: Views should set cache tags on its render arrays, and bubble the output's cache tags to the cache items written to the Views output cache
parent 94fb2afd
......@@ -544,11 +544,6 @@ protected function cacheSet(array &$elements, $pre_bubbling_cid) {
$data = $this->getCacheableRenderArray($elements);
// Cache tags are cached, but we also want to associate the "rendered" cache
// tag. This allows us to invalidate the entire render cache, regardless of
// the cache bin.
$data['#cache']['tags'][] = 'rendered';
$bin = isset($elements['#cache']['bin']) ? $elements['#cache']['bin'] : 'render';
$expire = isset($elements['#cache']['expire']) ? $elements['#cache']['expire'] : Cache::PERMANENT;
$cache = $this->cacheFactory->get($bin);
......@@ -690,7 +685,7 @@ protected function cacheSet(array &$elements, $pre_bubbling_cid) {
'tags' => Cache::mergeTags($stored_cache_tags, $data['#cache']['tags']),
],
];
$cache->set($pre_bubbling_cid, $redirect_data, $expire, $redirect_data['#cache']['tags']);
$cache->set($pre_bubbling_cid, $redirect_data, $expire, Cache::mergeTags($redirect_data['#cache']['tags'], ['rendered']));
}
// Current cache contexts incomplete: this request only uses a subset of
......@@ -711,7 +706,7 @@ protected function cacheSet(array &$elements, $pre_bubbling_cid) {
$data['#cache']['contexts'] = $merged_cache_contexts;
}
}
$cache->set($cid, $data, $expire, $data['#cache']['tags']);
$cache->set($cid, $data, $expire, Cache::mergeTags($data['#cache']['tags'], ['rendered']));
}
/**
......
......@@ -7,6 +7,11 @@
namespace Drupal\node\Tests\Views;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Url;
use Drupal\node\Entity\Node;
use Drupal\system\Tests\Cache\AssertPageCacheContextsAndTagsTrait;
use Drupal\views\Tests\AssertViewsCacheTagsTrait;
use Drupal\views\Tests\ViewTestBase;
use Drupal\views\ViewExecutable;
use Drupal\views\Views;
......@@ -18,6 +23,14 @@
*/
class FrontPageTest extends ViewTestBase {
use AssertPageCacheContextsAndTagsTrait;
use AssertViewsCacheTagsTrait;
/**
* {@inheritdoc}
*/
protected $dumpHeaders = TRUE;
/**
* The entity storage for nodes.
*
......@@ -35,7 +48,8 @@ class FrontPageTest extends ViewTestBase {
protected function setUp() {
parent::setUp();
$this->nodeStorage = $this->container->get('entity.manager')->getStorage('node');
$this->nodeStorage = $this->container->get('entity.manager')
->getStorage('node');
}
/**
......@@ -173,4 +187,166 @@ public function testAdminFrontPage() {
$this->assertPattern('/class=".+view-frontpage/', 'Frontpage view was rendered');
}
/**
* Tests the cache tags when using the "none" cache plugin.
*/
public function testCacheTagsWithCachePluginNone() {
$this->enablePageCaching();
$this->assertFrontPageViewCacheTags(FALSE);
}
/**
* Tests the cache tags when using the "tag" cache plugin.
*/
public function testCacheTagsWithCachePluginTag() {
$this->enablePageCaching();
$view = Views::getView('frontpage');
$view->setDisplay('page_1');
$view->display_handler->overrideOption('cache', [
'type' => 'tag',
]);
$view->save();
$this->assertFrontPageViewCacheTags(TRUE);
}
/**
* Tests the cache tags when using the "time" cache plugin.
*/
public function testCacheTagsWithCachePluginTime() {
$this->enablePageCaching();
$view = Views::getView('frontpage');
$view->setDisplay('page_1');
$view->display_handler->overrideOption('cache', [
'type' => 'time',
'options' => [
'results_lifespan' => 3600,
'output_lifespan' => 3600,
],
]);
$view->save();
$this->assertFrontPageViewCacheTags(TRUE);
}
/**
* Tests the cache tags on the front page.
*
* @param bool $do_assert_views_caches
* Whether to check Views' result & output caches.
*/
protected function assertFrontPageViewCacheTags($do_assert_views_caches) {
$view = Views::getView('frontpage');
$view->setDisplay('page_1');
$cache_contexts = [];
// Test before there are any nodes.
$empty_node_listing_cache_tags = [
'config:views.view.frontpage',
'node_list',
];
$this->assertViewsCacheTags(
$view,
$empty_node_listing_cache_tags,
$do_assert_views_caches,
$empty_node_listing_cache_tags
);
$this->assertPageCacheContextsAndTags(
Url::fromRoute('view.frontpage.page_1'),
$cache_contexts,
Cache::mergeTags($empty_node_listing_cache_tags, ['rendered'])
);
// Create some nodes on the frontpage view. Add more than 10 nodes in order
// to enable paging.
$this->drupalCreateContentType(['type' => 'article']);
for ($i = 0; $i < 15; $i++) {
$node = Node::create([
'body' => [
[
'value' => $this->randomMachineName(32),
'format' => filter_default_format(),
]
],
'type' => 'article',
'created' => $i,
'title' => $this->randomMachineName(8),
'nid' => $i + 1,
]);
$node->enforceIsNew(TRUE);
$node->save();
}
$cache_contexts = Cache::mergeContexts($cache_contexts, [
'theme',
'timezone',
'user.roles'
]);
// First page.
$first_page_result_cache_tags = [
'config:views.view.frontpage',
'node_list',
'node:6',
'node:7',
'node:8',
'node:9',
'node:10',
'node:11',
'node:12',
'node:13',
'node:14',
'node:15',
];
$first_page_output_cache_tags = Cache::mergeTags($first_page_result_cache_tags, [
'config:filter.format.plain_text',
'node_view',
'user_view',
'user:0',
]);
$view->setDisplay('page_1');
$view->setCurrentPage(0);
$this->assertViewsCacheTags(
$view,
$first_page_result_cache_tags,
$do_assert_views_caches,
$first_page_output_cache_tags
);
$this->assertPageCacheContextsAndTags(
Url::fromRoute('view.frontpage.page_1'),
$cache_contexts,
Cache::mergeTags($first_page_output_cache_tags, ['rendered'])
);
// Second page.
$this->assertPageCacheContextsAndTags(Url::fromRoute('view.frontpage.page_1', [], ['query' => ['page' => 1]]), $cache_contexts, [
// The cache tags for the listed nodes.
'node:1',
'node:2',
'node:3',
'node:4',
'node:5',
// The rest.
'config:filter.format.plain_text',
'config:views.view.frontpage',
'node_list',
'node_view',
'user_view',
'user:0',
'rendered',
]);
// Let's update a node title on the first page and ensure that the page
// cache entry invalidates.
$node = Node::load(10);
$title = $node->getTitle() . 'a';
$node->setTitle($title);
$node->save();
$this->drupalGet(Url::fromRoute('view.frontpage.page_1'));
$this->assertText($title);
}
}
<?php
/**
* @file
* Contains \Drupal\system\Tests\Cache\AssertPageCacheContextsAndTagsTrait.
*/
namespace Drupal\system\Tests\Cache;
use Drupal\Core\Url;
/**
* Provides test assertions for testing page-level cache contexts & tags.
*
* Can be used by test classes that extend \Drupal\simpletest\WebTestBase.
*/
trait AssertPageCacheContextsAndTagsTrait {
/**
* Enables page caching.
*/
protected function enablePageCaching() {
$config = $this->config('system.performance');
$config->set('cache.page.use_internal', 1);
$config->set('cache.page.max_age', 300);
$config->save();
}
/**
* Asserts page cache miss, then hit for the given URL; checks cache headers.
*
* @param \Drupal\Core\Url $url
* The URL to test.
* @param string[] $expected_contexts
* The expected cache contexts for the given URL.
* @param string[] $expected_tags
* The expected cache tags for the given URL.
*/
protected function assertPageCacheContextsAndTags(Url $url, array $expected_contexts, array $expected_tags) {
$absolute_url = $url->setAbsolute()->toString();
sort($expected_contexts);
sort($expected_tags);
$get_cache_header_values = function ($header_name) {
$header_value = $this->drupalGetHeader($header_name);
if (empty($header_value)) {
return [];
}
else {
return explode(' ', $header_value);
}
};
// Assert cache miss + expected cache contexts + tags.
$this->drupalGet($absolute_url);
$this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'MISS');
$actual_contexts = $get_cache_header_values('X-Drupal-Cache-Contexts');
$actual_tags = $get_cache_header_values('X-Drupal-Cache-Tags');
$this->assertIdentical($actual_contexts, $expected_contexts);
if ($actual_contexts !== $expected_contexts) {
debug(array_diff($actual_contexts, $expected_contexts));
}
$this->assertIdentical($actual_tags, $expected_tags);
if ($actual_tags !== $expected_tags) {
debug(array_diff($actual_tags, $expected_tags));
}
// Assert cache hit + expected cache contexts + tags.
$this->drupalGet($absolute_url);
$actual_contexts = $get_cache_header_values('X-Drupal-Cache-Contexts');
$actual_tags = $get_cache_header_values('X-Drupal-Cache-Tags');
$this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'HIT');
$this->assertIdentical($actual_contexts, $expected_contexts);
if ($actual_contexts !== $expected_contexts) {
debug(array_diff($actual_contexts, $expected_contexts));
}
$this->assertIdentical($actual_tags, $expected_tags);
if ($actual_tags !== $expected_tags) {
debug(array_diff($actual_tags, $expected_tags));
}
// Assert page cache item + expected cache tags.
$cid_parts = array($url->setAbsolute()->toString(), 'html');
$cid = implode(':', $cid_parts);
$cache_entry = \Drupal::cache('render')->get($cid);
sort($cache_entry->tags);
$this->assertEqual($cache_entry->tags, $expected_tags);
if ($cache_entry->tags !== $expected_tags) {
debug(array_diff($cache_entry->tags, $expected_tags));
}
}
}
......@@ -7,9 +7,7 @@
namespace Drupal\system\Tests\Cache;
use Drupal\Core\Url;
use Drupal\simpletest\WebTestBase;
use Drupal\Core\Cache\Cache;
/**
* Enables the page cache and tests its cache tags in various scenarios.
......@@ -21,6 +19,8 @@
*/
class PageCacheTagsIntegrationTest extends WebTestBase {
use AssertPageCacheContextsAndTagsTrait;
protected $profile = 'standard';
protected $dumpHeaders = TRUE;
......@@ -31,10 +31,7 @@ class PageCacheTagsIntegrationTest extends WebTestBase {
protected function setUp() {
parent::setUp();
$config = $this->config('system.performance');
$config->set('cache.page.use_internal', 1);
$config->set('cache.page.max_age', 300);
$config->save();
$this->enablePageCaching();
}
/**
......@@ -128,46 +125,10 @@ function testPageCacheTags() {
'config:system.menu.footer',
'config:system.menu.main',
'config:system.site',
'comment_list',
'node_list',
'config:views.view.comments_recent',
));
}
/**
* Asserts page cache miss, then hit for the given URL; checks cache headers.
*
* @param \Drupal\Core\Url $url
* The URL to test.
* @param string[] $expected_contexts
* The expected cache contexts for the given URL.
* @param string[] $expected_tags
* The expected cache tags for the given URL.
*/
protected function assertPageCacheContextsAndTags(Url $url, array $expected_contexts, array $expected_tags) {
$absolute_url = $url->setAbsolute()->toString();
sort($expected_contexts);
sort($expected_tags);
// Assert cache miss + expected cache contexts + tags.
$this->drupalGet($absolute_url);
$this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'MISS');
$actual_contexts = explode(' ', $this->drupalGetHeader('X-Drupal-Cache-Contexts'));
$actual_tags = explode(' ', $this->drupalGetHeader('X-Drupal-Cache-Tags'));
$this->assertIdentical($actual_contexts, $expected_contexts);
$this->assertIdentical($actual_tags, $expected_tags);
// Assert cache hit + expected cache contexts + tags.
$this->drupalGet($absolute_url);
$actual_contexts = explode(' ', $this->drupalGetHeader('X-Drupal-Cache-Contexts'));
$actual_tags = explode(' ', $this->drupalGetHeader('X-Drupal-Cache-Tags'));
$this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'HIT');
$this->assertIdentical($actual_contexts, $expected_contexts);
$this->assertIdentical($actual_tags, $expected_tags);
// Assert page cache item + expected cache tags.
$cid_parts = array($url->setAbsolute()->toString(), 'html');
$cid = implode(':', $cid_parts);
$cache_entry = \Drupal::cache('render')->get($cid);
sort($cache_entry->tags);
$this->assertEqual($cache_entry->tags, $expected_tags);
}
}
......@@ -191,7 +191,7 @@ public function cacheSet($type) {
// that is used to render the view for this request and rendering does
// not happen twice.
$this->storage = $this->view->display_handler->output = $this->renderer->getCacheableRenderArray($output);
\Drupal::cache($this->outputBin)->set($this->generateOutputKey(), $this->storage, $this->cacheSetExpire($type), $this->getCacheTags());
\Drupal::cache($this->outputBin)->set($this->generateOutputKey(), $this->storage, $this->cacheSetExpire($type), Cache::mergeTags($this->storage['#cache']['tags'], ['rendered']));
break;
}
}
......@@ -239,9 +239,6 @@ public function cacheGet($type) {
/**
* Clear out cached data for a view.
*
* We're just going to nuke anything related to the view, regardless of display,
* to be sure that we catch everything. Maybe that's a bad idea.
*/
public function cacheFlush() {
Cache::invalidateTags($this->view->storage->getCacheTags());
......@@ -301,12 +298,18 @@ public function generateResultsKey() {
'langcode' => \Drupal::languageManager()->getCurrentLanguage()->getId(),
'base_url' => $GLOBALS['base_url'],
);
foreach (array('exposed_info', 'page', 'sort', 'order', 'items_per_page', 'offset') as $key) {
foreach (array('exposed_info', 'sort', 'order') as $key) {
if ($this->view->getRequest()->query->has($key)) {
$key_data[$key] = $this->view->getRequest()->query->get($key);
}
}
$key_data['pager'] = [
'page' => $this->view->getCurrentPage(),
'items_per_page' => $this->view->getItemsPerPage(),
'offset' => $this->view->getOffset(),
];
$this->resultsKey = $this->view->storage->id() . ':' . $this->displayHandler->display['id'] . ':results:' . hash('sha256', serialize($key_data));
}
......@@ -343,18 +346,21 @@ public function generateOutputKey() {
* @return string[]
* An array of cache tags based on the current view.
*/
protected function getCacheTags() {
public function getCacheTags() {
$tags = $this->view->storage->getCacheTags();
// The list cache tags for the entity types listed in this view.
$entity_information = $this->view->query->getEntityTableInfo();
if (!empty($entity_information)) {
// Add the list cache tags for each entity type used by this view.
foreach (array_keys($entity_information) as $entity_type) {
$tags = Cache::mergeTags($tags, \Drupal::entityManager()->getDefinition($entity_type)->getListCacheTags());
foreach ($entity_information as $table => $metadata) {
$tags = Cache::mergeTags($tags, \Drupal::entityManager()->getDefinition($metadata['entity_type'])->getListCacheTags());
}
}
$tags = Cache::mergeTags($tags, $this->view->getQuery()->getCacheTags());
return $tags;
}
......
......@@ -2126,6 +2126,15 @@ public function render() {
'#post_render_cache' => &$this->view->element['#post_render_cache'],
);
if (!isset($element['#cache'])) {
$element['#cache'] = [];
}
$element['#cache'] += ['tags' => []];
// If the output is a render array, add cache tags, regardless of whether
// caching is enabled or not; cache tags must always be set.
$element['#cache']['tags'] = Cache::mergeTags($element['#cache']['tags'], $this->view->getCacheTags());
return $element;
}
......
......@@ -312,6 +312,13 @@ public function getEntityTableInfo() {
return $entity_tables;
}
/**
* {@inheritdoc}
*/
public function getCacheTags() {
return [];
}
}
/**
......
......@@ -8,6 +8,7 @@
namespace Drupal\views\Plugin\views\query;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Database\Database;
use Drupal\Core\Form\FormStateInterface;
use Drupal\views\Plugin\views\display\DisplayPluginBase;
......@@ -1537,6 +1538,23 @@ function loadEntities(&$results) {
}
}
/**
* {@inheritdoc}
*/
public function getCacheTags() {
$tags = [];
// Add cache tags for each row, if there is an entity associated with it.
if (!$this->hasAggregate) {
foreach ($this->view->result as $row) {
if ($row->_entity) {
$tags = Cache::mergeTags($row->_entity->getCacheTags(), $tags);
}
}
}
return $tags;
}
public function addSignature(ViewExecutable $view) {
$view->query->addField(NULL, "'" . $view->storage->id() . ':' . $view->current_display . "'", 'view_name');
}
......
<?php
/**
* @file
* Contains \Drupal\views\Tests\AssertViewsCacheTagsTrait.
*/
namespace Drupal\views\Tests;
use Drupal\Core\Cache\Cache;
use Drupal\views\ViewExecutable;
use Symfony\Component\HttpFoundation\Request;
trait AssertViewsCacheTagsTrait {
/**
* Asserts a view's result & output cache items' cache tags.
*
* @param \Drupal\views\ViewExecutable $view
* The view to test, must have caching enabled.
* @param null|string[] $expected_results_cache
* NULL when expecting no results cache item, a set of cache tags expected
* to be set on the results cache item otherwise.
* @param bool $views_caching_is_enabled
* Whether to expect an output cache item. If TRUE, the cache tags must
* match those in $expected_render_array_cache_tags.
* @param string[] $expected_render_array_cache_tags
* A set of cache tags expected to be set on the built view's render array.
*
* @return array
* The render array
*/
protected function assertViewsCacheTags(ViewExecutable $view, $expected_results_cache, $views_caching_is_enabled, array $expected_render_array_cache_tags) {
$build = $view->preview();
// Ensure the current request is a GET request so that render caching is
// active for direct rendering of views, just like for actual requests.
/** @var \Symfony\Component\HttpFoundation\RequestStack $request_stack */
$request_stack = \Drupal::service('request_stack');
$request_stack->push(new Request());
\Drupal::service('renderer')->renderRoot($build);
$request_stack->pop();
// Render array cache tags.
$this->pass('Checking render array cache tags.');
sort($expected_render_array_cache_tags);
$this->assertEqual($build['#cache']['tags'], $expected_render_array_cache_tags);
if ($views_caching_is_enabled) {
$this->pass('Checking Views results cache item cache tags.');
/** @var \Drupal\views\Plugin\views\cache\CachePluginBase $cache_plugin */
$cache_plugin = $view->display_handler->getPlugin('cache');
// Results cache.
$results_cache_item = \Drupal::cache('data')->get($cache_plugin->generateResultsKey());
if (is_array($expected_results_cache)) {
$this->assertTrue($results_cache_item, 'Results cache item found.');
if ($results_cache_item) {
sort($expected_results_cache);
$this->assertEqual($results_cache_item->tags, $expected_results_cache);
}
}
else {
$this->assertFalse($results_cache_item, 'Results cache item not found.');
}
// Output cache.
$this->pass('Checking Views output cache item cache tags.');
$output_cache_item = \Drupal::cache('render')->get($cache_plugin->generateOutputKey());
if ($views_caching_is_enabled === TRUE) {
$this->assertTrue($output_cache_item, 'Output cache item found.');
if ($output_cache_item) {
$this->assertEqual($output_cache_item->tags, Cache::mergeTags($expected_render_array_cache_tags, ['rendered']));
}
}
else {
$this->assertFalse($output_cache_item, 'Output cache item not found.');
}
}
$view->destroy();
return $build;
}
}
......@@ -9,6 +9,7 @@
use Drupal\Component\Utility\Unicode;
use Drupal\Core\Url;
use Drupal\system\Tests\Cache\AssertPageCacheContextsAndTagsTrait;
use Drupal\views\Views;
/**
......@@ -18,6 +19,9 @@
*/
class GlossaryTest extends ViewTestBase {
use AssertPageCacheContextsAndTagsTrait;
use AssertViewsCacheTagsTrait;
/**
* Modules to enable.
*
......@@ -39,6 +43,7 @@ public function testGlossaryView() {
'a' => 3,
'l' => 6,
);
$nodes_by_char = [];
foreach ($nodes_per_char as $char => $count) {
$setting = array(
'type' => $type->id()
......@@ -46,7 +51,8 @@ public function testGlossaryView() {
for ($i = 0; $i < $count; $i++) {
$node = $setting;
$node['title'] = $char . $this->randomString(3);
$this->drupalCreateNode($node);
$node = $this->drupalCreateNode($node);
$nodes_by_char[$char][] = $node;
}
}
......@@ -77,6 +83,16 @@ public function testGlossaryView() {
$result_count = trim(str_replace(array('|', '(', ')'), '', (string) $result[0]));
$this->assertEqual($result_count, $count, 'The expected number got rendered.');
}
// Verify cache tags.
$this->enablePageCaching();
$this->assertPageCacheContextsAndTags(Url::fromRoute('view.glossary.page_1'), [], [
'config:views.view.glossary',
'node:' . $nodes_by_char['a'][0]->id(), 'node:' . $nodes_by_char['a'][1]->id(), 'node:' . $nodes_by_char['a'][2]->id(),
'node_list',
'user_list',
'rendered',
]);
}
}
......@@ -44,7 +44,6 @@ protected function setUp() {
* @see views_plugin_cache_time
*/
public function testTimeResultCaching() {
// Create a basic result which just 2 results.
$view = Views::getView('test_cache');
$view->setDisplay();
$view->display_handler->overrideOption('cache', array(
......@@ -55,6 +54,7 @@ public function testTimeResultCaching() {
)
));
// Test the default (non-paged) display.
$this->executeView($view);
// Verify the result.
$this->assertEqual(5, count($view->result), 'The number of returned rows match.');
......@@ -67,7 +67,19 @@ public function testTimeResultCaching() {
);
db_insert('views_test_data')->fields($record)->execute();
// The Result should be the same as before, because of the caching.
// The result should be the same as before, because of the caching. (Note
// that views_test_data records don't have associated cache tags, and hence
// the results cache items aren't invalidated.)
$view->destroy();
$this->executeView($view);
// Verify the result.
$this->assertEqual(5, count($view->result), 'The number of returned rows match.');