diff --git a/src/Datasource/DatasourceInterface.php b/src/Datasource/DatasourceInterface.php index 5cd4ed1a..4845dfb0 100644 --- a/src/Datasource/DatasourceInterface.php +++ b/src/Datasource/DatasourceInterface.php @@ -137,9 +137,28 @@ public function getItemUrl(ComplexDataInterface $item); * * @return bool * TRUE if access is granted, FALSE otherwise. + * + * @deprecated in search_api:8.x-1.13 and will be removed from + * search_api:9.x-1.0. Use getItemAccessResult() instead. + * + * @see https://www.drupal.org/node/3051902 */ public function checkItemAccess(ComplexDataInterface $item, AccountInterface $account = NULL); + /** + * Checks whether a user has permission to view the given item. + * + * @param \Drupal\Core\TypedData\ComplexDataInterface $item + * An item of this datasource's type. + * @param \Drupal\Core\Session\AccountInterface|null $account + * (optional) The user session for which to check access, or NULL to check + * access for the current user. + * + * @return \Drupal\Core\Access\AccessResultInterface + * The access result. + */ + public function getItemAccessResult(ComplexDataInterface $item, AccountInterface $account = NULL); + /** * Returns the available view modes for this datasource. * @@ -238,4 +257,15 @@ public function getItemIds($page = NULL); */ public function getFieldDependencies(array $fields); + /** + * Returns the cacheability metadata for the given item. + * + * @param \Drupal\Core\TypedData\ComplexDataInterface $item + * The item of this datasource's type for which to return the metadata. + * + * @return \Drupal\Core\Cache\CacheableMetadata + * The cacheability metadata. + */ + public function getItemCacheableMetadata(ComplexDataInterface $item); + } diff --git a/src/Datasource/DatasourcePluginBase.php b/src/Datasource/DatasourcePluginBase.php index e6cfea03..bb43310d 100644 --- a/src/Datasource/DatasourcePluginBase.php +++ b/src/Datasource/DatasourcePluginBase.php @@ -2,6 +2,8 @@ namespace Drupal\search_api\Datasource; +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Language\Language; use Drupal\Core\Session\AccountInterface; use Drupal\Core\TypedData\ComplexDataInterface; @@ -97,7 +99,15 @@ public function getItemUrl(ComplexDataInterface $item) { * {@inheritdoc} */ public function checkItemAccess(ComplexDataInterface $item, AccountInterface $account = NULL) { - return TRUE; + @trigger_error('\Drupal\search_api\Datasource\DatasourceInterface::checkItemAccess() is deprecated in search_api:8.x-1.13. It will be removed from search_api:9.x-1.0. Use getItemAccessResult() instead. See https://www.drupal.org/node/3051902', E_USER_DEPRECATED); + return $this->getItemAccessResult($item, $account)->isAllowed(); + } + + /** + * {@inheritdoc} + */ + public function getItemAccessResult(ComplexDataInterface $item, AccountInterface $account = NULL) { + return AccessResult::allowed(); } /** @@ -155,4 +165,11 @@ public function getFieldDependencies(array $fields) { return []; } + /** + * {@inheritdoc} + */ + public function getItemCacheableMetadata(ComplexDataInterface $item) { + return new CacheableMetadata(); + } + } diff --git a/src/Item/Item.php b/src/Item/Item.php index d120b6f9..abb37062 100644 --- a/src/Item/Item.php +++ b/src/Item/Item.php @@ -2,6 +2,8 @@ namespace Drupal\search_api\Item; +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Session\AccountInterface; use Drupal\Core\TypedData\ComplexDataInterface; use Drupal\search_api\Datasource\DatasourceInterface; @@ -406,13 +408,43 @@ public function setExtraData($key, $data = NULL) { * {@inheritdoc} */ public function checkAccess(AccountInterface $account = NULL) { + @trigger_error('\Drupal\search_api\Item\ItemInterface::checkAccess() is deprecated in search_api:8.x-1.13. It will be removed from search_api:9.x-1.0. Use getAccessResult() instead. See https://www.drupal.org/node/3051902', E_USER_DEPRECATED); + return $this->getAccessResult($account)->isAllowed(); + } + + /** + * {@inheritdoc} + */ + public function getAccessResult(AccountInterface $account = NULL) { + // @fixme Statically cache this by account. try { return $this->getDatasource() - ->checkItemAccess($this->getOriginalObject(), $account); + ->getItemAccessResult($this->getOriginalObject(), $account); } catch (SearchApiException $e) { - return FALSE; + return AccessResult::neutral('Item could not be loaded, so cannot check access'); + } + } + + /** + * {@inheritdoc} + */ + public function getCacheableMetadata(AccountInterface $account = NULL) { + // @fixme Statically cache this by account. + $cacheability = new CacheableMetadata(); + $access = $this->getAccessResult($account); + $cacheability->addCacheableDependency($access); + if ($access->isAllowed()) { + try { + $item_cacheability = $this->getDatasource() + ->getItemCacheableMetadata($this->getOriginalObject()); + $cacheability->addCacheableDependency($item_cacheability); + } + catch (SearchApiException $e) { + // Ignore here. + } } + return $cacheability; } /** diff --git a/src/Item/ItemInterface.php b/src/Item/ItemInterface.php index a3fdbb6f..626af487 100644 --- a/src/Item/ItemInterface.php +++ b/src/Item/ItemInterface.php @@ -295,7 +295,35 @@ public function setExtraData($key, $data = NULL); * * @return bool * TRUE if access is granted, FALSE otherwise. + * + * @deprecated in search_api:8.x-1.13 and will be removed from + * search_api:9.x-1.0. Use getAccessResult() instead. + * + * @see https://www.drupal.org/node/3051902 */ public function checkAccess(AccountInterface $account = NULL); + /** + * Checks whether a user has permission to view this item. + * + * @param \Drupal\Core\Session\AccountInterface|null $account + * (optional) The user for which to check access, or NULL to check access + * for the current user. + * + * @return \Drupal\Core\Access\AccessResultInterface + * The access result. + */ + public function getAccessResult(AccountInterface $account = NULL); + + /** + * Returns cacheability metadata. + * + * @param \Drupal\Core\Session\AccountInterface|null $account + * The user for which to return the metadata. + * + * @return \Drupal\Core\Cache\CacheableMetadata + * The cacheability metadata. + */ + public function getCacheableMetadata(AccountInterface $account = NULL); + } diff --git a/src/Plugin/search_api/datasource/ContentEntity.php b/src/Plugin/search_api/datasource/ContentEntity.php index 69915d35..ccc7d8e0 100644 --- a/src/Plugin/search_api/datasource/ContentEntity.php +++ b/src/Plugin/search_api/datasource/ContentEntity.php @@ -3,6 +3,7 @@ namespace Drupal\search_api\Plugin\search_api\datasource; use Drupal\Component\Utility\Crypt; +use Drupal\Core\Access\AccessResult; use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Entity\ContentEntityInterface; @@ -684,13 +685,14 @@ public function getItemUrl(ComplexDataInterface $item) { /** * {@inheritdoc} */ - public function checkItemAccess(ComplexDataInterface $item, AccountInterface $account = NULL) { - if ($entity = $this->getEntity($item)) { + public function getItemAccessResult(ComplexDataInterface $item, AccountInterface $account = NULL) { + $entity = $this->getEntity($item); + if ($entity) { return $this->getEntityTypeManager() ->getAccessControlHandler($this->getEntityTypeId()) - ->access($entity, 'view', $account); + ->access($entity, 'view', $account, TRUE); } - return FALSE; + return AccessResult::neutral('Item is not an entity, so cannot check access'); } /** @@ -1169,4 +1171,18 @@ public static function filterValidItemIds(IndexInterface $index, $datasource_id, return $valid_ids; } + /** + * {@inheritdoc} + */ + public function getItemCacheableMetadata(ComplexDataInterface $item) { + $cacheability = parent::getItemCacheableMetadata($item); + + $entity = $this->getEntity($item); + if ($entity) { + $cacheability->addCacheableDependency($entity); + } + + return $cacheability; + } + } diff --git a/src/Plugin/views/cache/SearchApiCachePluginTrait.php b/src/Plugin/views/cache/SearchApiCachePluginTrait.php index 9f6e7100..df175bb1 100644 --- a/src/Plugin/views/cache/SearchApiCachePluginTrait.php +++ b/src/Plugin/views/cache/SearchApiCachePluginTrait.php @@ -173,8 +173,8 @@ public function cacheGet($type) { */ public function generateResultsKey() { if (!isset($this->resultsKey)) { - $query = $this->getQuery()->getSearchApiQuery(); - $query->preExecute(); + // @todo Why is this here? Is this needed? + $this->getQuery()->getSearchApiQuery()->preExecute(); $view = $this->getView(); $build_info = $view->build_info; diff --git a/src/Plugin/views/cache/SearchApiTagCache.php b/src/Plugin/views/cache/SearchApiTagCache.php index 48e7728f..f2ffb320 100644 --- a/src/Plugin/views/cache/SearchApiTagCache.php +++ b/src/Plugin/views/cache/SearchApiTagCache.php @@ -78,8 +78,14 @@ public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_man */ public function getCacheTags() { $tags = $this->view->storage->getCacheTags(); + // Add the list cache tag of the search index, so that the view will be + // invalidated whenever the index is updated. $tag = 'search_api_list:' . $this->getQuery()->getIndex()->id(); $tags = Cache::mergeTags([$tag], $tags); + // Also add the cache tags of the index itself, so that the view will be + // invalidated if the configuration of the index changes. + $index_tags = $this->getQuery()->getIndex()->getCacheTagsToInvalidate(); + $tags = Cache::mergeTags($index_tags, $tags); return $tags; } diff --git a/src/Plugin/views/query/SearchApiQuery.php b/src/Plugin/views/query/SearchApiQuery.php index f77a15aa..2d936cde 100644 --- a/src/Plugin/views/query/SearchApiQuery.php +++ b/src/Plugin/views/query/SearchApiQuery.php @@ -3,6 +3,8 @@ namespace Drupal\search_api\Plugin\views\query; use Drupal\Component\Render\FormattableMarkup; +use Drupal\Core\Cache\Cache; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Database\Query\ConditionInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; @@ -119,6 +121,33 @@ class SearchApiQuery extends QueryPluginBase { */ protected $messenger; + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * The cacheability metadata of the result. + * + * Set in ::addResults, so only available after ::execute. + * + * Used in the ::getCache*() methods. + * + * @var \Drupal\Core\Cache\RefinableCacheableDependencyInterface + */ + protected $cacheableMetadata; + + /** + * {@inheritdoc} + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + + $this->cacheableMetadata = new CacheableMetadata(); + } + /** * {@inheritdoc} */ @@ -129,6 +158,7 @@ public static function create(ContainerInterface $container, array $configuratio $plugin->setModuleHandler($container->get('module_handler')); $plugin->setMessenger($container->get('messenger')); $plugin->setLogger($container->get('logger.channel.search_api')); + $plugin->setEntityTypeManager($container->get('entity_type.manager')); return $plugin; } @@ -241,6 +271,29 @@ public function setMessenger(MessengerInterface $messenger) { return $this; } + /** + * Returns the entity type manager. + * + * @return \Drupal\Core\Entity\EntityTypeManagerInterface + * The entity type manager. + */ + public function getEntityTypeManager() { + return $this->entityTypeManager; + } + + /** + * Sets the entity type manager. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + * + * @return $this + */ + public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager) { + $this->entityTypeManager = $entity_type_manager; + return $this; + } + /** * {@inheritdoc} */ @@ -631,13 +684,13 @@ protected function addResults(ResultSetInterface $result_set, ViewExecutable $vi $count = 0; // First, unless disabled, check access for all entities in the results. + $account = $this->getAccessAccount(); if (!$this->options['skip_access']) { - $account = $this->getAccessAccount(); // If search items are not loaded already, pre-load them now in bulk to // avoid them being individually loaded inside checkAccess(). $result_set->preLoadResultItems(); foreach ($results as $item_id => $result) { - if (!$result->checkAccess($account)) { + if (!$result->getAccessResult($account)->isAllowed()) { unset($results[$item_id]); } } @@ -685,8 +738,65 @@ protected function addResults(ResultSetInterface $result_set, ViewExecutable $vi $view->result[] = new ResultRow($values); } + + // Retrieve the cacheability metadata from the result set. + $this->cacheableMetadata = $result_set->getCacheableMetadata($account); } + /** + * {@inheritdoc} + */ + public function getCacheContexts() { + $contexts = []; + + foreach ($this->getIndex()->getEntityTypes() as $entity_type_id) { + $entity_type_definition = $this->entityTypeManager->getDefinition($entity_type_id); + $contexts = Cache::mergeContexts($entity_type_definition->getListCacheContexts(), $contexts); + } + + return $contexts; + } + + /** + * {@inheritdoc} + */ + public function getCacheTags() { + $tags = parent::getCacheTags(); + + if ($this->getIndex()->getOption('index_directly')) { + // @todo If the entities are indexed directly we can add the cache tags + // of the entities themselves. + } + else { + // @todo If we are indexing asynchronously the best we can do is + // invalidate the result when the index changes. + // $this->getIndex()->getEntityType()->getListCacheTags(); + } + + // If the configuration of the search index changes we should invalidate the + // views that show results from this index. + $index_tags = $this->getIndex()->getCacheTagsToInvalidate(); + $tags = Cache::mergeTags($index_tags, $tags); + + return $tags; + } + + /** + * {@inheritdoc} + */ + public function getCacheMaxAge() { + $max_age = parent::getCacheMaxAge(); + + // @todo We should get the max ages from the viewed items. + // @ref \Drupal\views\Plugin\views\query\Sql::getCacheMaxAge() + // foreach ($this->getAllEntities() as $entity) { + // $max_age = Cache::mergeMaxAges($max_age, $entity->getCacheMaxAge()); + // } + + return $max_age; + } + + /** * Retrieves the conditions placed on this query. * diff --git a/src/Query/ResultSet.php b/src/Query/ResultSet.php index 781a9dfe..86992af4 100644 --- a/src/Query/ResultSet.php +++ b/src/Query/ResultSet.php @@ -2,6 +2,8 @@ namespace Drupal\search_api\Query; +use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Session\AccountInterface; use Drupal\search_api\Item\ItemInterface; use Drupal\search_api\SearchApiException; @@ -228,6 +230,18 @@ public function getIterator() { return new \ArrayIterator($this->resultItems); } + /** + * {@inheritdoc} + */ + public function getCacheableMetadata(AccountInterface $account = NULL) { + $cacheability = new CacheableMetadata(); + foreach ($this->getResultItems() as $item) { + $other_cacheability = $item->getCacheableMetadata($account); + $cacheability = $cacheability->merge($other_cacheability); + } + return $cacheability; + } + /** * Implements the magic __toString() method to simplify debugging. */ diff --git a/src/Query/ResultSetInterface.php b/src/Query/ResultSetInterface.php index a32a46ee..1b2e1f34 100644 --- a/src/Query/ResultSetInterface.php +++ b/src/Query/ResultSetInterface.php @@ -2,6 +2,7 @@ namespace Drupal\search_api\Query; +use Drupal\Core\Session\AccountInterface; use Drupal\search_api\Item\ItemInterface; /** @@ -197,4 +198,15 @@ public function setExtraData($key, $data = NULL); */ public function getCloneForQuery(QueryInterface $query); + /** + * Returns cacheability metadata. + * + * @param \Drupal\Core\Session\AccountInterface|null $account + * The user account for which to return the metadata. + * + * @return \Drupal\Core\Cache\CacheableMetadata + * The cacheability metadata. + */ + public function getCacheableMetadata(AccountInterface $account = NULL); + } diff --git a/tests/search_api_test/search_api_test.module b/tests/search_api_test/search_api_test.module index deeb41a7..eb14868a 100644 --- a/tests/search_api_test/search_api_test.module +++ b/tests/search_api_test/search_api_test.module @@ -14,7 +14,7 @@ function search_api_test_node_grants(AccountInterface $account, $op) { $grants = []; - if (\Drupal::state()->get('search_api_test_add_node_access_grant', TRUE)) { + if (\Drupal::state()->get('search_api_test_add_node_access_grant', FALSE)) { $grants['search_api_test'] = [$account->id()]; } @@ -27,7 +27,7 @@ function search_api_test_node_grants(AccountInterface $account, $op) { function search_api_test_node_access_records(NodeInterface $node) { $grants = []; - if (\Drupal::state()->get('search_api_test_add_node_access_grant', TRUE)) { + if (\Drupal::state()->get('search_api_test_add_node_access_grant', FALSE)) { $grants[] = [ 'realm' => 'search_api_test', 'gid' => $node->getOwnerId(), diff --git a/tests/search_api_test_node_indexing/config/install/search_api.index.test_node_index.yml b/tests/search_api_test_node_indexing/config/install/search_api.index.test_node_index.yml new file mode 100644 index 00000000..b2af7e1d --- /dev/null +++ b/tests/search_api_test_node_indexing/config/install/search_api.index.test_node_index.yml @@ -0,0 +1,48 @@ +id: test_node_index +name: 'Test node index' +description: 'An index of node entities used for testing' +read_only: false +field_settings: + status: + label: Published + datasource_id: 'entity:node' + property_path: status + type: boolean + dependencies: + module: + - node + title: + label: Title + datasource_id: 'entity:node' + property_path: title + type: string + dependencies: + module: + - node +processor_settings: + add_url: { } + aggregated_field: { } + rendered_item: { } +options: + cron_limit: -1 + index_directly: false +datasource_settings: + 'entity:node': + bundles: + default: true + selected: { } + languages: + default: true + selected: { } +tracker_settings: + default: + indexing_order: fifo +server: database_search_server +status: true +langcode: en +dependencies: + config: + - search_api.server.database_search_server + module: + - node + - search_api diff --git a/tests/search_api_test_node_indexing/config/install/search_api.server.database_search_server.yml b/tests/search_api_test_node_indexing/config/install/search_api.server.database_search_server.yml new file mode 100644 index 00000000..0e79fda4 --- /dev/null +++ b/tests/search_api_test_node_indexing/config/install/search_api.server.database_search_server.yml @@ -0,0 +1,13 @@ +id: database_search_server +name: 'Database search server' +description: 'A server used for testing' +backend: search_api_db +backend_config: + database: 'default:default' + min_chars: 3 + matching: words +status: true +langcode: en +dependencies: + module: + - search_api_db diff --git a/tests/search_api_test_node_indexing/config/install/views.view.search_api_test_node_view.yml b/tests/search_api_test_node_indexing/config/install/views.view.search_api_test_node_view.yml new file mode 100644 index 00000000..0ee6bce9 --- /dev/null +++ b/tests/search_api_test_node_indexing/config/install/views.view.search_api_test_node_view.yml @@ -0,0 +1,107 @@ +langcode: en +status: true +dependencies: + config: + - search_api.index.test_node_index + module: + - search_api +id: search_api_test_node_view +label: 'Search API test node view' +module: views +description: '' +tag: '' +base_table: search_api_index_test_node_index +base_field: search_api_id +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: none + options: { } + cache: + type: search_api_tag + options: { } + query: + type: search_api_query + options: + bypass_access: false + skip_access: false + preserve_facet_query_args: false + exposed_form: + type: basic + options: + submit_button: Search + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: full + options: + items_per_page: 10 + offset: 0 + id: 0 + total_pages: null + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 20, 40, 60' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + tags: + previous: ‹‹ + next: ›› + style: + type: default + row: + type: fields + fields: + title: + id: title + table: search_api_index_test_node_index + field: title + plugin_id: search_api_field + filters: { } + sorts: { } + title: 'Search API test node view' + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - 'user.node_grants:view' + tags: + - 'config:search_api.index.test_node_index' + page_1: + display_plugin: page + id: page_1 + display_title: Page + position: 1 + display_options: + display_extenders: { } + path: search-api-test-node-view + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - 'user.node_grants:view' + tags: + - 'config:search_api.index.test_node_index' diff --git a/tests/search_api_test_node_indexing/search_api_test_node_indexing.info.yml b/tests/search_api_test_node_indexing/search_api_test_node_indexing.info.yml new file mode 100644 index 00000000..10cdc56f --- /dev/null +++ b/tests/search_api_test_node_indexing/search_api_test_node_indexing.info.yml @@ -0,0 +1,8 @@ +type: module +name: 'Search API node indexing test' +description: 'Test module for testing indexing of nodes in Search API.' +package: 'Search API' +dependencies: + - search_api:search_api_db +core: 8.x +hidden: true diff --git a/tests/src/Kernel/Processor/ContentAccessTest.php b/tests/src/Kernel/Processor/ContentAccessTest.php index 0d287112..df59c584 100644 --- a/tests/src/Kernel/Processor/ContentAccessTest.php +++ b/tests/src/Kernel/Processor/ContentAccessTest.php @@ -48,6 +48,9 @@ class ContentAccessTest extends ProcessorTestBase { public function setUp($processor = NULL) { parent::setUp('content_access'); + // Activate our custom grant. + \Drupal::state()->set('search_api_test_add_node_access_grant', TRUE); + // Create a node type for testing. $type = NodeType::create(['type' => 'page', 'name' => 'page']); $type->save(); diff --git a/tests/src/Kernel/Views/ViewsCacheInvalidationTest.php b/tests/src/Kernel/Views/ViewsCacheInvalidationTest.php new file mode 100644 index 00000000..a855ae43 --- /dev/null +++ b/tests/src/Kernel/Views/ViewsCacheInvalidationTest.php @@ -0,0 +1,458 @@ +installSchema('node', ['node_access']); + $this->installSchema('search_api', ['search_api_item']); + $this->installSchema('system', ['sequences']); + + $this->installEntitySchema('node'); + $this->installEntitySchema('search_api_task'); + $this->installEntitySchema('user'); + + $this->installConfig([ + 'node', + 'search_api', + 'search_api_test_node_indexing', + 'search_api_test_views', + ]); + + $this->entityTypeManager = $this->container->get('entity_type.manager'); + $this->viewExecutableFactory = $this->container->get('views.executable'); + $this->renderer = $this->container->get('renderer'); + $this->renderCache = $this->container->get('render_cache'); + $this->cacheTagsInvalidator = $this->container->get('cache_tags.invalidator'); + $this->currentUser = $this->container->get('current_user'); + + DateFormat::create([ + 'id' => 'fallback', + 'label' => 'Fallback', + 'pattern' => 'Y-m-d', + ])->save(); + + // Use the test search index from the search_api_test_db module. + $this->index = Index::load('test_node_index'); + + // Create a test content type. + $this->contentType = NodeType::create([ + 'name' => 'Page', + 'type' => 'page', + ]); + $this->contentType->save(); + + // Create some test content and index it. + foreach (['Cheery' => TRUE, 'Carrot' => TRUE, 'Detritus' => FALSE] as $title => $status) { + $this->createNode($title, $status); + } + $this->index->indexItems(); + + // Create a dummy test user. This user will get UID 1 which is handled as + // the root user and can bypass all access restrictions. This is not used + // in the test. + $this->createUser(); + + // Create two test users, one with permission to view unpublished entities, + // and one without. + $this->users['no-access'] = $this->createUser(['access content']); + $this->users['has-access'] = $this->createUser(['access content', 'bypass node access']); + } + + /** + * Tests that a cached views display is invalidated at the right occasions. + */ + public function testDisplayCacheInvalidation() { + // We are testing two variants of the view, one for users that have + // permission to view test entities, and one for users that do not. + // Initially both variants should be uncached. + $this->assertNotCached('no-access'); + $this->assertNotCached('has-access'); + + // Check that the user with the 'bypass node access' permission can see all + // 3 items. + $this->assertViewsResult('has-access', ['Cheery', 'Carrot', 'Detritus']); + + // The result should now be cached for the privileged user. + $this->assertNotCached('no-access'); + $this->assertCached('has-access'); + + // Check that the user without the 'bypass node access' permission can only + // see the published items. + $this->assertViewsResult('no-access', ['Cheery', 'Carrot']); + + // Both results should now be cached. + $this->assertCached('no-access'); + $this->assertCached('has-access'); + + // Add another unpublished item. + $this->createNode('Angua', FALSE); + + // Our search index is not configured to automatically index items, so just + // creating a node should not invalidate the caches. + $this->assertCached('no-access'); + $this->assertCached('has-access'); + + // Index the item, this should invalidate the caches. + $this->index->indexItems(); + $this->assertNotCached('no-access'); + $this->assertNotCached('has-access'); + + // Check that the user without the 'bypass node access' permission can still + // only see the published items. + $this->assertViewsResult('no-access', ['Cheery', 'Carrot']); + $this->assertCached('no-access'); + $this->assertNotCached('has-access'); + + // Check that the user with the 'bypass node access' permission can see all + // 4 items. + $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Carrot', 'Detritus']); + $this->assertCached('no-access'); + $this->assertCached('has-access'); + + // Grant the permission to 'bypass node access' to the unprivileged user. + $privileged_role = $this->users['has-access']->getRoles()[1]; + $this->users['no-access']->addRole($privileged_role); + $this->users['no-access']->save(); + + // The user should now be able to see all 4 items. + $this->assertViewsResult('no-access', ['Angua', 'Cheery', 'Carrot', 'Detritus']); + $this->assertCached('no-access'); + $this->assertCached('has-access'); + + // Edit one of the test content entities. This should not affect the cached + // view until the search index is updated. + $this->nodes['Cheery']->set('title', 'Cheery Littlebottom')->save(); + $this->assertCached('no-access'); + $this->assertCached('has-access'); + + $this->index->indexItems(); + $this->assertNotCached('no-access'); + $this->assertNotCached('has-access'); + + // The view should show the updated title when displayed, and the result + // should be cached. + $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Carrot', 'Detritus']); + $this->assertCached('has-access'); + + // Delete one of the test content entities. This takes effect immediately, + // there is no need to wait until the search index is updated. + // @see search_api_entity_delete() + $this->nodes['Carrot']->delete(); + $this->assertNotCached('has-access'); + + // The view should no longer include the deleted content now, and the result + // should be cached after the view has been displayed. + $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Detritus']); + $this->assertCached('has-access'); + + // Update the search index configuration so it will index items immediately + // when they are created or updated. + $this->index->setOption('index_directly', TRUE)->save(); + + // Changing the configuration of the index should invalidate all views that + // show its data. + $this->assertNotCached('no-access'); + $this->assertNotCached('has-access'); + + // Check that the expected results are still returned and are cacheable. + $this->assertViewsResult('no-access', ['Angua', 'Cheery', 'Detritus']); + $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Detritus']); + $this->assertCached('no-access'); + $this->assertCached('has-access'); + + // Change the configuration of the view. This should also invalidate all + // displays of the view. + $view = $this->getView(); + $view->setItemsPerPage(20); + $view->save(); + $this->assertNotCached('no-access'); + $this->assertNotCached('has-access'); + + // Check that the expected results are still returned and are cacheable. + $this->assertViewsResult('no-access', ['Angua', 'Cheery', 'Detritus']); + $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Detritus']); + $this->assertCached('no-access'); + $this->assertCached('has-access'); + + // Edit one of the test content entities. Because the search index is being + // updated immediately, the cached views should be cleared without having to + // perform a manual indexing step. + $this->nodes['Angua']->set('title', 'Angua von Überwald')->save(); + $this->assertNotCached('no-access'); + $this->assertNotCached('has-access'); + + // Check that the updated results are shown and are cacheable. + $this->assertViewsResult('no-access', ['Angua', 'Cheery', 'Detritus']); + $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Detritus']); + $this->assertCached('no-access'); + $this->assertCached('has-access'); + } + + /** + * Checks that the view is cached for the given user. + * + * @param string $user_key + * The key of the user for which to perform the check. + */ + protected function assertCached($user_key) { + $this->doAssertCached('assertNotEmpty', $user_key); + } + + /** + * Checks that the view is not cached for the given user. + * + * @param string $user_key + * The key of the user for which to perform the check. + */ + protected function assertNotCached($user_key) { + $this->doAssertCached('assertEmpty', $user_key); + } + + /** + * Checks the cache status of the view for the given user. + * + * @param string $assert_method + * The method to use for asserting that the view is cached or not cached. + * @param int $user_key + * The key of the user for which to perform the check. + */ + protected function doAssertCached($assert_method, $user_key) { + // Ensure that any post request indexing is done. This is normally handled + // at the end of the request but since we are running a KernelTest we are + // not executing any requests and need to trigger this manually. + $this->triggerPostRequestIndexing(); + + // Set the user that will be used to check the cache status. + $this->setCurrentUser($user_key); + + // Retrieve the cached data and perform the assertion. + $view = $this->getView(); + $view->build(); + /** @var \Drupal\views\Plugin\views\cache\CachePluginBase $cache */ + $cache = $view->getDisplay()->getPlugin('cache'); + $cached_data = $cache->cacheGet('results'); + + $this->$assert_method($cached_data); + } + + /** + * Checks that the view for the given user contains the expected results. + * + * @param string $user_key + * The key of the user to check. + * @param array $node_keys + * The keys of the nodes that are expected to be present in the result set. + */ + protected function assertViewsResult($user_key, array $node_keys) { + // Clear the static caches of the cache tags invalidators. The invalidators + // will only invalidate cache tags once per request to improve performance. + // Unfortunately they cannot distinguish between an actual Drupal page + // request and a PHPUnit test that simulates visiting multiple pages. + // We are pretending that every time this method is called a new page has + // been requested, and the static caches are empty. + $this->cacheTagsInvalidator->resetChecksums(); + + $this->setCurrentUser($user_key); + + $render_array = $this->getRenderableView(); + $html = (string) $this->renderer->renderRoot($render_array); + + // Check that the titles of the expected results are present. + foreach ($node_keys as $node_key) { + $label = $this->nodes[$node_key]->label(); + $this->assertContains($label, $html); + } + + // Also check that none of the titles of the remaining search items are + // unexpectedly present. + $unexpected_keys = array_diff(array_keys($this->nodes), $node_keys); + foreach ($unexpected_keys as $unexpected_key) { + $label = $this->nodes[$unexpected_key]->label(); + $this->assertNotContains($label, $html); + } + } + + /** + * Sets the user with the given key as the currently active user. + * + * @param string $user_key + * The key of the user to set as currently active user. + */ + protected function setCurrentUser($user_key) { + $this->currentUser->setAccount($this->users[$user_key]); + } + + /** + * Returns the test view as a render array. + * + * @return array|null + * The render array, or NULL if the view cannot be rendered. + */ + protected function getRenderableView() { + $render_array = $this->getView()->buildRenderable(); + $render_array['#cache']['contexts'] = Cache::mergeContexts($render_array['#cache']['contexts'], $this->container->getParameter('renderer.config')['required_cache_contexts']); + + return $render_array; + } + + /** + * Returns the test view. + * + * @return \Drupal\views\ViewExecutable + * The view. + */ + protected function getView() { + /** @var \Drupal\views\ViewEntityInterface $view */ + $view = $this->entityTypeManager->getStorage('view')->load(self::TEST_VIEW_ID); + $executable = $this->viewExecutableFactory->get($view); + $executable->setDisplay(self::TEST_VIEW_DISPLAY_ID); + return $executable; + } + + /** + * Creates a node with the given title and publication status. + * + * @param string $title + * The title for the node. + * @param bool $status + * The publication status to set. + * + * @throws \Drupal\Core\Entity\EntityStorageException + * Thrown if an error occurred during the saving of the node. + */ + protected function createNode($title, $status) { + $values = [ + 'title' => $title, + 'status' => $status, + 'type' => $this->contentType->id(), + ]; + $this->nodes[$title] = Node::create($values); + $this->nodes[$title]->save(); + } + +} diff --git a/tests/src/Kernel/Views/ViewsDisplayCachingTest.php b/tests/src/Kernel/Views/ViewsDisplayCachingTest.php index ce6d8505..9d669d6a 100644 --- a/tests/src/Kernel/Views/ViewsDisplayCachingTest.php +++ b/tests/src/Kernel/Views/ViewsDisplayCachingTest.php @@ -317,15 +317,16 @@ public function displayCacheabilityProvider() { // Views. This is expected to disable caching. [ 'none', - // It is expected that only the configuration of the view itself is - // available as a cache tag. + // Cache tags for index and view config are included at the query level, + // so should still be present even when disabling caching. [ + 'config:search_api.index.database_search_index', 'config:views.view.search_api_test_cache', ], // No specific cache contexts are expected to be present. [], - // It is expected that the cache max-age is set to zero, effectively - // disabling the cache. + // The cache max-age should be returned as zero, effectively disabling + // the cache. 0, // It is expected that no results are cached. FALSE, @@ -337,9 +338,9 @@ public function displayCacheabilityProvider() { [ 'tag', [ - // It is expected that the configuration of the view itself is - // available as a cache tag, so that the caches are invalidated if the - // view configuration changes. + // The cache should be invalidated when either index or view are + // modified. + 'config:search_api.index.database_search_index', 'config:views.view.search_api_test_cache', // The view shows an entity, so it should be invalidated when that // entity changes. @@ -362,9 +363,9 @@ public function displayCacheabilityProvider() { [ 'time', [ - // It is expected that the configuration of the view itself is - // available as a cache tag, so that the caches are invalidated if the - // view configuration changes. No other tags should be available. + // The cache should be invalidated when either index or view are + // modified. + 'config:search_api.index.database_search_index', 'config:views.view.search_api_test_cache', ], // No specific cache contexts are expected to be present.