Commit 15c848bd authored by catch's avatar catch

Issue #2381277 by dawehner, plach, damiankloip, alexpott, olli, fgm, Wim...

Issue #2381277 by dawehner, plach, damiankloip, alexpott, olli, fgm, Wim Leers, Fabianx: Make Views use render caching and remove Views' own "output caching"
parent 82141437
......@@ -42,7 +42,7 @@ public function getCacheableRenderArray(array $elements);
* @param array $elements
* A renderable array.
*
* @return array
* @return array|false
* A renderable array, with the original element and all its children pre-
* rendered, or FALSE if no cached copy of the element is available.
*
......
......@@ -79,7 +79,7 @@ public function render($row) {
'#options' => $this->options,
'#row' => $item,
);
return drupal_render_root($build);
return $build;
}
}
......@@ -210,6 +210,31 @@ function comment_node_links_alter(array &$node_links, NodeInterface $node, array
$node_links += $links;
}
/**
* Implements hook_entity_view().
*/
function comment_entity_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode, $langcode) {
if ($entity instanceof FieldableEntityInterface && $view_mode == 'rss' && $display->getComponent('links')) {
/** @var \Drupal\comment\CommentManagerInterface $comment_manager */
$comment_manager = \Drupal::service('comment.manager');
$fields = $comment_manager->getFields($entity->getEntityTypeId());
foreach ($fields as $field_name => $detail) {
if ($entity->hasField($field_name) && $entity->get($field_name)->status != CommentItemInterface::HIDDEN) {
// Add a comments RSS element which is a URL to the comments of this
// entity.
$options = array(
'fragment' => 'comments',
'absolute' => TRUE,
);
$entity->rss_elements[] = array(
'key' => 'comments',
'value' => $entity->url('canonical', $options),
);
}
}
}
}
/**
* Implements hook_ENTITY_TYPE_view_alter() for node entities.
*/
......
......@@ -82,11 +82,12 @@ public function __construct(AccountInterface $current_user, CommentManagerInterf
public function buildCommentedEntityLinks(FieldableEntityInterface $entity, array &$context) {
$entity_links = array();
$view_mode = $context['view_mode'];
if ($view_mode == 'search_index' || $view_mode == 'search_result' || $view_mode == 'print') {
if ($view_mode == 'search_index' || $view_mode == 'search_result' || $view_mode == 'print' || $view_mode == 'rss') {
// Do not add any links if the entity is displayed for:
// - search indexing.
// - constructing a search result excerpt.
// - print.
// - rss.
return array();
}
......@@ -101,19 +102,7 @@ public function buildCommentedEntityLinks(FieldableEntityInterface $entity, arra
if ($commenting_status != CommentItemInterface::HIDDEN) {
// Entity has commenting status open or closed.
$field_definition = $entity->getFieldDefinition($field_name);
if ($view_mode == 'rss') {
// Add a comments RSS element which is a URL to the comments of this
// entity.
$options = array(
'fragment' => 'comments',
'absolute' => TRUE,
);
$entity->rss_elements[] = array(
'key' => 'comments',
'value' => $entity->url('canonical', $options),
);
}
elseif ($view_mode == 'teaser') {
if ($view_mode == 'teaser') {
// Teaser view: display the number of comments that have been posted,
// or a link to add new comments if the user has permission, the
// entity is open to new comments, and there currently are none.
......
......@@ -84,7 +84,7 @@ public function render($row) {
return;
}
$item_text = '';
$description_build = [];
$comment->link = $comment->url('canonical', array('absolute' => TRUE));
$comment->rss_namespaces = array();
......@@ -115,14 +115,16 @@ public function render($row) {
if ($view_mode != 'title') {
// We render comment contents.
$item_text .= drupal_render_root($build);
$description_build = $build;
}
$item = new \stdClass();
$item->description = $item_text;
$item->description = $description_build;
$item->title = $comment->label();
$item->link = $comment->link;
$item->elements = $comment->rss_elements;
// Provide a reference so that the render call in
// template_preprocess_views_view_row_rss() can still access it.
$item->elements = &$comment->rss_elements;
$item->cid = $comment->id();
$build = array(
......@@ -131,7 +133,7 @@ public function render($row) {
'#options' => $this->options,
'#row' => $item,
);
return drupal_render_root($build);
return $build;
}
}
......@@ -8,6 +8,8 @@
namespace Drupal\comment\Tests;
use Drupal\comment\Plugin\Field\FieldType\CommentItemInterface;
use Drupal\Core\Entity\Entity\EntityViewDisplay;
use Drupal\system\Tests\Cache\AssertPageCacheContextsAndTagsTrait;
/**
* Tests comments as part of an RSS feed.
......@@ -16,6 +18,8 @@
*/
class CommentRssTest extends CommentTestBase {
use AssertPageCacheContextsAndTagsTrait;
/**
* Modules to install.
*
......@@ -23,6 +27,22 @@ class CommentRssTest extends CommentTestBase {
*/
public static $modules = array('views');
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Setup the rss view display.
EntityViewDisplay::create([
'status' => TRUE,
'targetEntityType' => 'node',
'bundle' => 'article',
'mode' => 'rss',
'content' => ['links' => ['weight' => 100]],
])->save();
}
/**
* Tests comments as part of an RSS feed.
*/
......@@ -31,6 +51,18 @@ function testCommentRss() {
$this->drupalLogin($this->webUser);
$this->postComment($this->node, $this->randomMachineName(), $this->randomMachineName());
$this->drupalGet('rss.xml');
$this->assertCacheTags([
'config:views.view.frontpage', 'node:1', 'node_list', 'node_view', 'user:3',
]);
$this->assertCacheContexts([
'languages:language_interface',
'theme',
'user.node_grants:view',
'user.permissions',
'timezone',
]);
$raw = '<comments>' . $this->node->url('canonical', array('fragment' => 'comments', 'absolute' => TRUE)) . '</comments>';
$this->assertRaw($raw, 'Comments as part of RSS feed.');
......
......@@ -154,18 +154,6 @@ public function testCommentLinkBuilder(NodeInterface $node, $context, $has_acces
else {
$this->assertSame($links, $expected);
}
if ($context['view_mode'] == 'rss' && $node->get('comment')->status) {
$found = FALSE;
if ($node->get('comment')->status) {
foreach ($node->rss_elements as $element) {
if ($element['key'] == 'comments') {
$found = TRUE;
break;
}
}
}
$this->assertTrue($found);
}
}
/**
......
......@@ -20,7 +20,7 @@ display:
options:
perm: 'access content'
cache:
type: none
type: tag
options: { }
empty:
area_text_custom:
......
......@@ -111,7 +111,7 @@ public function render($row) {
return;
}
$item_text = '';
$description_build = [];
$node->link = $node->url('canonical', array('absolute' => TRUE));
$node->rss_namespaces = array();
......@@ -154,22 +154,25 @@ public function render($row) {
if ($display_mode != 'title') {
// We render node contents.
$item_text .= drupal_render_root($build);
$description_build = $build;
}
$item = new \stdClass();
$item->description = SafeMarkup::set($item_text);
$item->description = $description_build;
$item->title = $node->label();
$item->link = $node->link;
$item->elements = $node->rss_elements;
// Provide a reference so that the render call in
// template_preprocess_views_view_row_rss() can still access it.
$item->elements = &$node->rss_elements;
$item->nid = $node->id();
$theme_function = array(
$build = array(
'#theme' => $this->themeFunctions(),
'#view' => $this->view,
'#options' => $this->options,
'#row' => $item,
);
return drupal_render_root($theme_function);
return $build;
}
}
......@@ -11,7 +11,6 @@
use Drupal\Core\Language\LanguageInterface;
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;
......@@ -24,7 +23,6 @@
*/
class FrontPageTest extends ViewTestBase {
use AssertPageCacheContextsAndTagsTrait;
use AssertViewsCacheTagsTrait;
/**
......@@ -293,6 +291,7 @@ protected function assertFrontPageViewCacheTags($do_assert_views_caches) {
'timezone',
]);
$this->pass('First page');
// First page.
$first_page_result_cache_tags = [
'config:views.view.frontpage',
......@@ -329,6 +328,7 @@ protected function assertFrontPageViewCacheTags($do_assert_views_caches) {
);
// Second page.
$this->pass('Second page');
$this->assertPageCacheContextsAndTags(Url::fromRoute('view.frontpage.page_1', [], ['query' => ['page' => 1]]), $cache_contexts, [
// The cache tags for the listed nodes.
'node:1',
......
......@@ -8,15 +8,11 @@
namespace Drupal\rest\Plugin\views\display;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\CacheableResponse;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\State\StateInterface;
use Drupal\Core\Routing\RouteProviderInterface;
use Drupal\views\Plugin\views\display\ResponseDisplayPluginInterface;
use Drupal\views\ViewExecutable;
use Drupal\views\Plugin\views\display\PathPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Routing\RouteCollection;
/**
......@@ -33,7 +29,7 @@
* returns_response = TRUE
* )
*/
class RestExport extends PathPluginBase {
class RestExport extends PathPluginBase implements ResponseDisplayPluginInterface {
/**
* Overrides \Drupal\views\Plugin\views\display\DisplayPluginBase::$usesAJAX.
......@@ -74,49 +70,6 @@ class RestExport extends PathPluginBase {
*/
protected $mimeType;
/**
* The renderer
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* Constructs a Drupal\rest\Plugin\ResourceBase object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin_id for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\Core\Routing\RouteProviderInterface $route_provider
* The route provider
* @param \Drupal\Core\State\StateInterface $state
* The state key value store.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, RouteProviderInterface $route_provider, StateInterface $state, RendererInterface $renderer) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $route_provider, $state);
$this->renderer = $renderer;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('router.route_provider'),
$container->get('state'),
$container->get('renderer')
);
}
/**
* {@inheritdoc}
*/
......@@ -279,36 +232,32 @@ public function collectRoutes(RouteCollection $collection) {
/**
* {@inheritdoc}
*/
public function execute() {
parent::execute();
public static function buildResponse($view_id, $display_id, array $args = []) {
$build = static::buildBasicRenderable($view_id, $display_id, $args);
$output = $this->view->render();
/** @var \Drupal\Core\Render\RendererInterface $renderer */
$renderer = \Drupal::service('renderer');
$header = [];
$header['Content-type'] = $this->getMimeType();
if ($this->view->getRequest()->getFormat($header['Content-type']) !== 'html') {
// This display plugin is primarily for returning non-HTML formats.
// However, we still invoke the renderer to collect cacheability metadata.
// Because the renderer is designed for HTML rendering, it filters
// #markup for XSS unless it is already known to be safe, but that filter
// only works for HTML. Therefore, we mark the contents as safe to bypass
// the filter. So long as we are returning this in a non-HTML response
// (checked above), this is safe, because an XSS attack only works when
// executed by an HTML agent.
// @todo Decide how to support non-HTML in the render API in
// https://www.drupal.org/node/2501313.
$output['#markup'] = SafeMarkup::set($output['#markup']);
}
$response = new CacheableResponse($this->renderer->renderRoot($output), 200);
$cache_metadata = CacheableMetadata::createFromRenderArray($output);
$output = $renderer->renderRoot($build);
$response = new CacheableResponse($output, 200);
$cache_metadata = CacheableMetadata::createFromRenderArray($build);
$response->addCacheableDependency($cache_metadata);
$response->headers->set('Content-type', $build['#content_type']);
return $response;
}
/**
* {@inheritdoc}
*/
public function execute() {
parent::execute();
return $this->view->render();
}
/**
* {@inheritdoc}
*/
......@@ -316,22 +265,30 @@ public function render() {
$build = array();
$build['#markup'] = $this->view->style_plugin->render();
// Wrap the output in a pre tag if this is for a live preview.
$this->view->element['#content_type'] = $this->getMimeType();
$this->view->element['#cache_properties'][] = '#content_type';
// Wrap the output in a pre tag if this is for a live preview.
if (!empty($this->view->live_preview)) {
$build['#prefix'] = '<pre>';
$build['#markup'] = SafeMarkup::checkPlain($build['#markup']);
$build['#suffix'] = '</pre>';
}
elseif ($this->view->getRequest()->getFormat($this->view->element['#content_type']) !== 'html') {
// This display plugin is primarily for returning non-HTML formats.
// However, we still invoke the renderer to collect cacheability metadata.
// Because the renderer is designed for HTML rendering, it filters
// #markup for XSS unless it is already known to be safe, but that filter
// only works for HTML. Therefore, we mark the contents as safe to bypass
// the filter. So long as we are returning this in a non-HTML response
// (checked above), this is safe, because an XSS attack only works when
// executed by an HTML agent.
// @todo Decide how to support non-HTML in the render API in
// https://www.drupal.org/node/2501313.
$build['#markup'] = SafeMarkup::set($build['#markup']);
}
// Defaults for bubbleable rendering metadata.
$build['#cache']['tags'] = isset($build['#cache']['tags']) ? $build['#cache']['tags'] : array();
$build['#cache']['max-age'] = isset($build['#cache']['max-age']) ? $build['#cache']['max-age'] : Cache::PERMANENT;
/** @var \Drupal\views\Plugin\views\cache\CachePluginBase $cache */
$cache = $this->getPlugin('cache');
$build['#cache']['tags'] = Cache::mergeTags($build['#cache']['tags'], $cache->getCacheTags());
$build['#cache']['max-age'] = Cache::mergeMaxAges($build['#cache']['max-age'], $cache->getCacheMaxAge());
parent::applyDisplayCachablityMetadata($build);
return $build;
}
......
......@@ -8,6 +8,7 @@
namespace Drupal\rest\Plugin\views\style;
use Drupal\Core\Form\FormStateInterface;
use Drupal\views\Plugin\CacheablePluginInterface;
use Drupal\views\ViewExecutable;
use Drupal\views\Plugin\views\display\DisplayPluginBase;
use Drupal\views\Plugin\views\style\StylePluginBase;
......@@ -26,7 +27,7 @@
* display_types = {"data"}
* )
*/
class Serializer extends StylePluginBase {
class Serializer extends StylePluginBase implements CacheablePluginInterface {
/**
* Overrides \Drupal\views\Plugin\views\style\StylePluginBase::$usesRowPlugin.
......@@ -149,4 +150,18 @@ public function getFormats() {
return $this->options['formats'];
}
/**
* {@inheritdoc}
*/
public function isCacheable() {
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getCacheContexts() {
return ['request_format'];
}
}
......@@ -260,29 +260,6 @@ protected function rebuildCache() {
$this->container->get('router.builder')->rebuild();
}
/**
* Check if a HTTP response header exists and has the expected value.
*
* @param string $header
* The header key, example: Content-Type
* @param string $value
* The header value.
* @param string $message
* (optional) A message to display with the assertion.
* @param string $group
* (optional) The group this message is in, which is displayed in a column
* in test output. Use 'Debug' to indicate this is debugging output. Do not
* translate this string. Defaults to 'Other'; most tests do not override
* this default.
*
* @return bool
* TRUE if the assertion succeeded, FALSE otherwise.
*/
protected function assertHeader($header, $value, $message = '', $group = 'Browser') {
$header_value = $this->drupalGetHeader($header);
return $this->assertTrue($header_value == $value, $message ? $message : 'HTTP response header ' . $header . ' with value ' . $value . ' found.', $group);
}
/**
* {@inheritdoc}
*
......
......@@ -9,7 +9,10 @@
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Core\Cache\Cache;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\system\Tests\Cache\AssertPageCacheContextsAndTagsTrait;
use Drupal\views\Entity\View;
use Drupal\views\Plugin\views\display\DisplayPluginBase;
use Drupal\views\Views;
use Drupal\views\Tests\Plugin\PluginTestBase;
use Drupal\views\Tests\ViewTestData;
......@@ -79,6 +82,7 @@ public function testSerializerResponses() {
$actual_json = $this->drupalGetWithFormat('test/serialize/field', 'json');
$this->assertResponse(200);
$this->assertCacheTags($view->getCacheTags());
$this->assertCacheContexts(['languages:language_interface', 'theme', 'request_format']);
// @todo Due to https://www.drupal.org/node/2352009 we can't yet test the
// propagation of cache max-age.
......@@ -135,6 +139,7 @@ public function testSerializerResponses() {
$expected_cache_tags = Cache::mergeTags($expected_cache_tags, $entity->getCacheTags());
}
$this->assertCacheTags($expected_cache_tags);
$this->assertCacheContexts(['languages:language_interface', 'theme', 'entity_test_view_grants', 'request_format']);
$expected = $serializer->serialize($entities, 'hal_json');
$actual_json = $this->drupalGetWithFormat('test/serialize/entity', 'hal_json');
......@@ -156,6 +161,7 @@ public function testSerializerResponses() {
$expected = $serializer->serialize($entities, 'xml');
$actual_xml = $this->drupalGet('test/serialize/entity');
$this->assertIdentical($actual_xml, $expected, 'The expected XML output was found.');
$this->assertCacheContexts(['languages:language_interface', 'theme', 'entity_test_view_grants', 'request_format']);
// Allow multiple formats.
$view->setDisplay('rest_export_1');
......@@ -178,6 +184,111 @@ public function testSerializerResponses() {
$this->assertIdentical($actual_xml, $expected, 'The expected XML output was found.');
}
/**
* Sets up a request on the request stack with a specified format.
*
* @param string $format
* The new request format.
*/
protected function addRequestWithFormat($format) {
$request = \Drupal::request();
$request = clone $request;
$request->setRequestFormat($format);
\Drupal::requestStack()->push($request);
}
/**
* Tests REST export with views render caching enabled.
*/
public function testRestRenderCaching() {
$this->drupalLogin($this->adminUser);
/** @var \Drupal\Core\Render\RenderCacheInterface $render_cache */
$render_cache = \Drupal::service('render_cache');
// Enable render caching for the views.
/** @var \Drupal\views\ViewEntityInterface $storage */
$storage = View::load('test_serializer_display_entity');
$options = &$storage->getDisplay('default');
$options['display_options']['cache'] = [
'type' => 'tag',
];
$storage->save();
$original = DisplayPluginBase::buildBasicRenderable('test_serializer_display_entity', 'rest_export_1');
// Ensure that there is no corresponding render cache item yet.
$original['#cache'] += ['contexts' => []];
$original['#cache']['contexts'] = Cache::mergeContexts($original['#cache']['contexts'], $this->container->getParameter('renderer.config')['required_cache_contexts']);
$cache_tags = [
'config:views.view.test_serializer_display_entity',
'entity_test:1',
'entity_test:10',
'entity_test:2',
'entity_test:3',
'entity_test:4',
'entity_test:5',
'entity_test:6',
'entity_test:7',
'entity_test:8',
'entity_test:9',
'entity_test_list'
];
$cache_contexts = [
'entity_test_view_grants',
'languages:language_interface',
'theme',
'request_format',
];
$this->assertFalse($render_cache->get($original));
// Request the page, once in XML and once in JSON to ensure that the caching
// varies by it.
$result1 = $this->drupalGetJSON('test/serialize/entity');
$this->addRequestWithFormat('json');
$this->assertHeader('content-type', 'application/json');
$this->assertCacheContexts($cache_contexts);
$this->assertCacheTags($cache_tags);
$this->assertTrue($render_cache->get($original));
$result_xml = $this->drupalGetWithFormat('test/serialize/entity', 'xml');
$this->addRequestWithFormat('xml');
$this->assertHeader('content-type', 'text/xml; charset=UTF-8');
$this->assertCacheContexts($cache_contexts);
$this->assertCacheTags($cache_tags);
$this->assertTrue($render_cache->get($original));
// Ensure that the XML output is different from the JSON one.
$this->assertNotEqual($result1, $result_xml);
// Ensure that the cached page works.
$result2 = $this->drupalGetJSON('test/serialize/entity');
$this->addRequestWithFormat('json');
$this->assertHeader('content-type', 'application/json');
$this->assertEqual($result2, $result1);
$this->assertCacheContexts($cache_contexts);
$this->assertCacheTags($cache_tags);
$this->assertTrue($render_cache->get($original));
// Create a new entity and ensure that the cache tags are taken over.
EntityTest::create(['name' => 'test_11', 'user_id' => $this->adminUser->id()])->save();
$result3 = $this->drupalGetJSON('test/serialize/entity');
$this->addRequestWithFormat('json');
$this->assertHeader('content-type', 'application/json');
$this->assertNotEqual($result3, $result2);
// Add the new entity cache tag and remove the first one, because we just
// show 10 items in total.
$cache_tags[] = 'entity_test:11';
unset($cache_tags[array_search('entity_test:1', $cache_tags)]);
$this->assertCacheContexts($cache_contexts);
$this->assertCacheTags($cache_tags);
$this->assertTrue($render_cache->get($original));
}
/**
* Tests the response format configuration.
*/
......@@ -192,9 +303,11 @@ public function testResponseFormatConfiguration() {
// Should return a 406.
$this->drupalGetWithFormat('test/serialize/field', 'json');
$this->assertHeader('content-type', 'application/json');
$this->assertResponse(406, 'A 406 response was returned when JSON was requested.');
// Should return a 200.
$this->drupalGetWithFormat('test/serialize/field', 'xml');
$this->assertHeader('content-type', 'text/xml; charset=UTF-8');
$this->assertResponse(200, 'A 200 response was returned when XML was requested.');
// Add 'json' as an accepted format, so we have multiple.
......@@ -204,20 +317,31 @@ public function testResponseFormatConfiguration() {
// Should return a 200.
// @todo This should be fixed when we have better content negotiation.
$this->drupalGet('test/serialize/field');
$this->assertHeader('content-type', 'application/json');
$this->assertResponse(200, 'A 200 response was returned when any format was requested.');
// Should return a 200. Emulates a sample Firefox header.
$this->drupalGet('test/serialize/field', array(), array('Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'));
$this->assertHeader('content-type', 'application/json');
$this->assertResponse(200, 'A 200 response was returned when a browser accept header was requested.');