diff --git a/config/schema/search_api_solr.backend.schema.yml b/config/schema/search_api_solr.backend.schema.yml index 68d4978..a9b2481 100644 --- a/config/schema/search_api_solr.backend.schema.yml +++ b/config/schema/search_api_solr.backend.schema.yml @@ -67,6 +67,9 @@ search_api.backend.plugin.search_api_solr: http_method: type: string label: 'Use Get or Post for the Solr Queries' + site_hash: + type: string + label: 'Site hash' solr_version: type: string label: 'Solr version override' diff --git a/src/Plugin/search_api/backend/SearchApiSolrBackend.php b/src/Plugin/search_api/backend/SearchApiSolrBackend.php index 2d732e1..229d207 100644 --- a/src/Plugin/search_api/backend/SearchApiSolrBackend.php +++ b/src/Plugin/search_api/backend/SearchApiSolrBackend.php @@ -1175,12 +1175,11 @@ class SearchApiSolrBackend extends BackendPluginBase { * An array describing facets that apply to the current results. */ protected function extractFacets(QueryInterface $query, Result $resultset) { - $facets = array(); - if (!$resultset->getFacetSet()) { - return $facets; + return array(); } + $facets = array(); $index = $query->getIndex(); $field_names = $this->getFieldNames($index); $fields = $index->getFields(); @@ -1356,9 +1355,11 @@ class SearchApiSolrBackend extends BackendPluginBase { */ protected function setFacets(array $facets, array $field_names, Query $solarium_query) { $fq = array(); - if (!$facets) { + + if (empty($facets)) { return; } + $facet_set = $solarium_query->getFacetSet(); $facet_set->setSort('count'); $facet_set->setLimit(10); @@ -1373,7 +1374,7 @@ class SearchApiSolrBackend extends BackendPluginBase { // String fields have their own corresponding facet fields. $field = $field_names[$info['field']]; // Check for the "or" operator. - if (isset($info['operator']) && $info['operator'] === 'or') { + if (isset($info['operator']) && strtolower($info['operator']) === 'or') { // Remember that filters for this field should be tagged. $escaped = SearchApiSolrUtility::escapeFieldName($field_names[$info['field']]); $taggedFields[$escaped] = "{!tag=$escaped}"; @@ -1393,11 +1394,16 @@ class SearchApiSolrBackend extends BackendPluginBase { if ($info['min_count'] != 1) { $facet_field->setMinCount($info['min_count']); } + // Set missing, if specified. if ($info['missing']) { $facet_field->setMissing(TRUE); } + else { + $facet_field->setMissing(FALSE); + } } + // Tag filters of fields with "OR" facets. foreach ($taggedFields as $field => $tag) { $regex = '#(?indexStorage = \Drupal::entityTypeManager()->getStorage('search_api_index'); + + $this->drupalLogin($this->adminUser); + } + + /** + * Tests various operations via the Search API's admin UI. + */ + public function testFramework() { + $filepath = drupal_get_path('module', 'search_api_solr') . '/vendor/autoload.php'; + if (!class_exists('Solarium\\Client') && ($filepath != DRUPAL_ROOT . '/core/vendor/autoload.php')) { + require $filepath; + } + + // Login as an admin user for the rest of the tests. + $this->drupalLogin($this->adminUser); + + $this->createServer(); + $this->createIndex(); + $this->checkContentEntityTracking(); + $this->changeIndexServer(); + } + + /** + * Tests creating a search server via the UI. + */ + protected function createServer($server_id = '_test_server') { + $this->serverId = $server_id; + $server_name = 'Search API &{}<>! Server'; + $server_description = 'A >server< used for testing &.'; + $edit_path = 'admin/config/search/search-api/add-server'; + + $this->drupalGet($edit_path); + $this->assertResponse(200, 'Server add page exists'); + + $edit = array( + 'name' => '', + 'status' => 1, + 'description' => 'A server used for testing.', + 'backend' => 'search_api_solr', + ); + + $this->drupalPostForm($edit_path, $edit, $this->t('Save')); + $this->assertText($this->t('@name field is required.', array('@name' => $this->t('Server name')))); + + $edit = array( + 'name' => $server_name, + 'status' => 1, + 'description' => $server_description, + 'backend' => 'search_api_solr', + ); + $this->drupalPostForm($edit_path, $edit, $this->t('Save')); + $this->assertText($this->t('@name field is required.', array('@name' => $this->t('Machine-readable name')))); + + $edit = array( + 'name' => $server_name, + 'id' => $this->serverId, + 'status' => 1, + 'description' => $server_description, + 'backend' => 'search_api_solr', + ); + + $this->drupalPostForm(NULL, $edit, $this->t('Save')); + + $this->assertText($this->t('The server was successfully saved.')); + $this->assertUrl('admin/config/search/search-api/server/' . $this->serverId, array(), 'Correct redirect to server page.'); + $this->assertHtmlEscaped($server_name); + $this->assertHtmlEscaped($server_description); + + // Go back in and configure solr. + $edit_path = 'admin/config/search/search-api/server/' . $this->serverId . '/edit'; + $this->drupalGet($edit_path); + $edit = [ + 'backend_config[host]' => 'localhost', + 'backend_config[port]' =>'8983', + 'backend_config[path]' =>'/solr/d8', + ]; + $this->drupalPostForm(NULL, $edit, $this->t('Save')); + + $this->drupalGet('admin/config/search/search-api'); + $this->assertHtmlEscaped($server_name); + $this->assertHtmlEscaped($server_description); + } + + /** + * Tests creating a search index via the UI. + */ + protected function createIndex() { + $settings_path = 'admin/config/search/search-api/add-index'; + $this->indexId = 'test_index'; + $index_description = 'An >index< used for &! tęsting.'; + $index_name = 'Search >API< test &!^* index'; + + $this->drupalGet($settings_path); + $this->assertResponse(200); + $edit = array( + 'status' => 1, + 'description' => $index_description, + ); + + $this->drupalPostForm(NULL, $edit, $this->t('Save')); + $this->assertText($this->t('@name field is required.', array('@name' => $this->t('Index name')))); + $this->assertText($this->t('@name field is required.', array('@name' => $this->t('Machine-readable name')))); + $this->assertText($this->t('@name field is required.', array('@name' => $this->t('Data sources')))); + + + $edit = array( + 'name' => $index_name, + 'id' => $this->indexId, + 'status' => 1, + 'description' => $index_description, + 'server' => $this->serverId, + 'datasources[]' => array('entity:node'), + ); + + $this->drupalPostForm(NULL, $edit, $this->t('Save')); + + $this->assertText($this->t('The index was successfully saved.')); + // @todo Make this work correctly. + // $this->assertUrl($this->getIndexPath('fields/add'), array(), 'Correct redirect to index page.'); + $this->assertHtmlEscaped($index_name); + + $this->drupalGet($this->getIndexPath('edit')); + $this->assertHtmlEscaped($index_name); + + $this->indexStorage->resetCache(array($this->indexId)); + /** @var $index \Drupal\search_api\IndexInterface */ + $index = $this->indexStorage->load($this->indexId); + + if ($this->assertTrue($index, 'Index was correctly created.')) { + $this->assertEqual($index->label(), $edit['name'], 'Name correctly inserted.'); + $this->assertEqual($index->id(), $edit['id'], 'Index ID correctly inserted.'); + $this->assertTrue($index->status(), 'Index status correctly inserted.'); + $this->assertEqual($index->getDescription(), $edit['description'], 'Index ID correctly inserted.'); + $this->assertEqual($index->getServerId(), $edit['server'], 'Index server ID correctly inserted.'); + $this->assertEqual($index->getDatasourceIds(), $edit['datasources[]'], 'Index datasource id correctly inserted.'); + } + else { + // Since none of the other tests would work, bail at this point. + throw new SearchApiException(); + } + + // Test the "Save and edit" button. + $index2_id = 'test_index2'; + $edit['id'] = $index2_id; + unset($edit['server']); + $this->drupalPostForm($settings_path, $edit, $this->t('Save and edit')); + + $this->assertText($this->t('The index was successfully saved.')); + $this->indexStorage->resetCache(array($index2_id)); + $index = $this->indexStorage->load($index2_id); + $this->assertUrl($index->toUrl('add-fields'), array(), 'Correct redirect to index fields page.'); + + $this->drupalGet('admin/config/search/search-api'); + $this->assertHtmlEscaped($index_name); + $this->assertHtmlEscaped($index_description); + } + + /** + * Tests whether the tracking information is properly maintained. + * + * Will especially test the bundle option of the content entity datasource. + */ + protected function checkContentEntityTracking() { + // Initially there should be no tracked items, because there are no nodes. + $tracked_items = $this->countTrackedItems(); + $this->assertEqual($tracked_items, 0, 'No items are tracked yet.'); + + // Add two articles and a page. + $article1 = $this->drupalCreateNode(array('type' => 'article')); + $this->drupalCreateNode(array('type' => 'article')); + $this->drupalCreateNode(array('type' => 'page')); + + // Those 3 new nodes should be added to the tracking table immediately. + $tracked_items = $this->countTrackedItems(); + $this->assertEqual($tracked_items, 3, 'Three items are tracked.'); + + $node_count = \Drupal::entityQuery('node')->count()->execute(); + $this->assertEqual($node_count, $tracked_items); + + // Test disabling the index. + $settings_path = $this->getIndexPath('edit'); + $this->drupalGet($settings_path); + $edit = array( + 'status' => FALSE, + 'datasource_configs[entity:node][default]' => 0, + 'datasource_configs[entity:node][bundles][article]' => FALSE, + 'datasource_configs[entity:node][bundles][page]' => FALSE, + ); + $this->drupalPostForm(NULL, $edit, $this->t('Save')); + + $tracked_items = $this->countTrackedItems(); + $this->assertEqual($tracked_items, 0, 'No items are tracked.'); + + // Test re-enabling the index. + $this->drupalGet($settings_path); + + $edit = array( + 'status' => TRUE, + 'datasource_configs[entity:node][default]' => 0, + 'datasource_configs[entity:node][bundles][article]' => TRUE, + 'datasource_configs[entity:node][bundles][page]' => TRUE, + ); + $this->drupalPostForm(NULL, $edit, $this->t('Save')); + + $tracked_items = $this->countTrackedItems(); + $this->assertEqual($tracked_items, 3, 'Three items are tracked.'); + + // Uncheck "default" and don't select any bundles. This should remove all + // items from the tracking table. + $edit = array( + 'status' => TRUE, + 'datasource_configs[entity:node][default]' => 0, + 'datasource_configs[entity:node][bundles][article]' => FALSE, + 'datasource_configs[entity:node][bundles][page]' => FALSE, + ); + $this->drupalPostForm($settings_path, $edit, $this->t('Save')); + + $tracked_items = $this->countTrackedItems(); + $this->assertEqual($tracked_items, 0, 'No items are tracked.'); + + // Leave "default" unchecked and select the "article" bundle. This should + // re-add the two articles to the tracking table. + $edit = array( + 'status' => TRUE, + 'datasource_configs[entity:node][default]' => 0, + 'datasource_configs[entity:node][bundles][article]' => TRUE, + 'datasource_configs[entity:node][bundles][page]' => FALSE, + ); + $this->drupalPostForm($settings_path, $edit, $this->t('Save')); + + $tracked_items = $this->countTrackedItems(); + $this->assertEqual($tracked_items, 2, 'Two items are tracked.'); + + // Leave "default" unchecked and select only the "page" bundle. This should + // result in only the page being present in the tracking table. + $edit = array( + 'status' => TRUE, + 'datasource_configs[entity:node][default]' => 0, + 'datasource_configs[entity:node][bundles][article]' => FALSE, + 'datasource_configs[entity:node][bundles][page]' => TRUE, + ); + $this->drupalPostForm($settings_path, $edit, $this->t('Save')); + + $tracked_items = $this->countTrackedItems(); + $this->assertEqual($tracked_items, 1, 'One item is tracked.'); + + // Check "default" again and select the "article" bundle. This shouldn't + // change the tracking table, which should still only contain the page. + $edit = array( + 'status' => TRUE, + 'datasource_configs[entity:node][default]' => 1, + 'datasource_configs[entity:node][bundles][article]' => TRUE, + 'datasource_configs[entity:node][bundles][page]' => FALSE, + ); + $this->drupalPostForm($settings_path, $edit, $this->t('Save')); + + $tracked_items = $this->countTrackedItems(); + $this->assertEqual($tracked_items, 1, 'One item is tracked.'); + + // Leave "default" checked but now select only the "page" bundle. This + // should result in only the articles being tracked. + $edit = array( + 'status' => TRUE, + 'datasource_configs[entity:node][default]' => 1, + 'datasource_configs[entity:node][bundles][article]' => FALSE, + 'datasource_configs[entity:node][bundles][page]' => TRUE, + ); + $this->drupalPostForm($settings_path, $edit, $this->t('Save')); + + $tracked_items = $this->countTrackedItems(); + $this->assertEqual($tracked_items, 2, 'Two items are tracked.'); + + // Delete an article. That should remove it from the item table. + $article1->delete(); + + $tracked_items = $this->countTrackedItems(); + $this->assertEqual($tracked_items, 1, 'One item is tracked.'); + + // Go back to the default setting to continue the test. + $edit = array( + 'status' => TRUE, + 'datasource_configs[entity:node][default]' => 1, + 'datasource_configs[entity:node][bundles][article]' => FALSE, + 'datasource_configs[entity:node][bundles][page]' => FALSE, + ); + $this->drupalPostForm($settings_path, $edit, $this->t('Save')); + + $tracked_items = $this->countTrackedItems(); + $this->assertEqual($tracked_items, 2, 'Two items are tracked.'); + $node_count = \Drupal::entityQuery('node')->count()->execute(); + $this->assertEqual($node_count, $tracked_items, 'All nodes are correctly tracked by the index.'); + } + + /** + * Counts the number of tracked items in the test index. + * + * @return int + * The number of tracked items in the test index. + */ + protected function countTrackedItems() { + return $this->getIndex()->getTrackerInstance()->getTotalItemsCount(); + } + + /** + * Counts the number of unindexed items in the test index. + * + * @return int + * The number of unindexed items in the test index. + */ + protected function countRemainingItems() { + return $this->getIndex()->getTrackerInstance()->getRemainingItemsCount(); + } + + /** + * Changes the index's server and checks if it reacts correctly. + * + * The expected behavior is that, when an index's server is changed, all of + * the index's items should be removed from the previous server and marked as + * "unindexed" in the tracker. + */ + protected function changeIndexServer() { + $this->indexStorage->resetCache(array($this->indexId)); + /** @var $index \Drupal\search_api\IndexInterface */ + $index = $this->indexStorage->load($this->indexId); + + $node_count = \Drupal::entityQuery('node')->count()->execute(); + $this->assertEqual($node_count, $this->countTrackedItems(), 'All nodes are correctly tracked by the index.'); + + // Index all remaining items on the index. + $index->indexItems(); + + $remaining_items = $this->countRemainingItems(); + $this->assertEqual($remaining_items, 0, 'All items have been successfully indexed.'); + + // Create a second search server. + $this->createServer('test_server_2'); + + // Change the index's server to the new one. + $settings_path = $this->getIndexPath('edit'); + $edit = array( + 'server' => $this->serverId, + ); + $this->drupalPostForm($settings_path, $edit, $this->t('Save')); + + // After saving the new index, we should have called reindex. + $remaining_items = $this->countRemainingItems(); + $this->assertEqual($remaining_items, $node_count, 'All items still need to be indexed.'); + } + + /** + * Retrieves test index. + * + * @return \Drupal\search_api\IndexInterface + * The test index. + */ + protected function getIndex() { + return Index::load($this->indexId); + } + + /** + * Ensures that all occurrences of the string are properly escaped. + * + * This makes sure that the string is only mentioned in an escaped version and + * is never double escaped. + * + * @param string $string + * The raw string to check for. + */ + protected function assertHtmlEscaped($string) { + $this->assertRaw(Html::escape($string)); + $this->assertNoRaw(Html::escape(Html::escape($string))); + $this->assertNoRaw($string); + } + +} diff --git a/tests/modules/search_api_test_solr/config/install/search_api.index.solr_search_index.yml b/tests/modules/search_api_test_solr/config/install/search_api.index.solr_search_index.yml index d7db38d..0472612 100644 --- a/tests/modules/search_api_test_solr/config/install/search_api.index.solr_search_index.yml +++ b/tests/modules/search_api_test_solr/config/install/search_api.index.solr_search_index.yml @@ -2,7 +2,7 @@ id: solr_search_index name: 'Test index' description: 'An index used for testing' read_only: false -fields: +field_settings: id: label: ID type: integer @@ -40,25 +40,28 @@ fields: property_path: search_api_language index_locked: true type_locked: true -processors: +processor_settings: add_url: - processor_id: add_url - weights: - preprocess_index: -30 - settings: { } + plugin_id: add_url + settings: + weights: + preprocess_index: -30 language: - processor_id: language - weights: - preprocess_index: -50 - settings: { } + plugin_id: language + settings: + weights: + preprocess_index: -50 options: cron_limit: -1 index_directly: false -datasources: - - 'entity:entity_test' -datasource_configs: { } -tracker: default -tracker_config: { } +datasource_settings: + 'entity:entity_test': + plugin_id: 'entity:entity_test' + settings: { } +tracker_settings: + 'default': + plugin_id: default + settings: { } server: solr_search_server status: true langcode: en diff --git a/tests/src/Kernel/SearchApiSolrTest.php b/tests/src/Kernel/SearchApiSolrTest.php index e62bea3..df4a70a 100644 --- a/tests/src/Kernel/SearchApiSolrTest.php +++ b/tests/src/Kernel/SearchApiSolrTest.php @@ -10,11 +10,12 @@ namespace Drupal\Tests\search_api_solr\Kernel; use Drupal\search_api\Entity\Index; use Drupal\search_api\Entity\Server; use Drupal\search_api\Query\ResultSetInterface; +use Drupal\search_api\Utility; use Drupal\Tests\search_api_db\Kernel\BackendTest; /** * Tests index and search capabilities using the Solr search backend. - * + * * @group search_api_solr */ class SearchApiSolrTest extends BackendTest { @@ -62,7 +63,7 @@ class SearchApiSolrTest extends BackendTest { $this->installConfig(array('search_api_test_solr')); - // Because this is a EntityUnitTest, the routing isn't built by default, so + // Because this is a kernel test, the routing isn't built by default, so // we have to force it. \Drupal::service('router.builder')->rebuild(); @@ -73,8 +74,15 @@ class SearchApiSolrTest extends BackendTest { $this->solrAvailable = TRUE; } } - catch (\Exception $e) { - } + catch (\Exception $e) {} + } + + /** + * Clear the index after every test. + */ + public function tearDown() { + $this->clearIndex(); + parent::tearDown(); } /** @@ -86,11 +94,64 @@ class SearchApiSolrTest extends BackendTest { parent::testFramework(); } else { - $this->pass('Error: The Solr instance could not be found. Please enable a multi-core one on http://localhost:8983/solr/d8'); + $this->assertTrue(TRUE, 'Error: The Solr instance could not be found. Please enable a multi-core one on http://localhost:8983/solr/d8'); } } /** + * Tests facets. + */ + public function testFacets() { + $this->insertExampleContent(); + $this->indexItems($this->indexId); + + // Create a query object. + $query = Utility::createQuery($this->getIndex()); + + // Add a condition on the query object, to filter on category. + $conditions = $query->createConditionGroup('OR', array('facet:category')); + $conditions->addCondition('category', 'article_category'); + $query->addConditionGroup($conditions); + + // Add facet to the query. + $facets['category'] = array( + 'field' => 'category', + 'limit' => 10, + 'min_count' => 1, + 'missing' => TRUE, + 'operator' => 'or', + ); + $query->setOption('search_api_facets', $facets); + + // Get the result. + $results = $query->execute(); + + $expected_results = array( + 'entity:entity_test/4:en', + 'entity:entity_test/5:en', + ); + + // Asserts that the result count is correct, as well as that the entities 4 + // and 5 returned. And that the added condition actually filtered out the + // results so that the category of the returned results is article_category. + $this->assertEquals($expected_results, array_keys($results->getResultItems())); + $this->assertEquals(array('article_category'), $results->getResultItems()['entity:entity_test/4:en']->getField('category')->getValues()); + $this->assertEquals(array('article_category'), $results->getResultItems()['entity:entity_test/5:en']->getField('category')->getValues()); + $this->assertEquals(2, $results->getResultCount(), 'OR facets query returned correct number of results.'); + + $expected = array( + array('count' => 2, 'filter' => '"article_category"'), + array('count' => 2, 'filter' => '"item_category"'), + array('count' => 1, 'filter' => '!'), + ); + $category_facets = $results->getExtraData('search_api_facets')['category']; + usort($category_facets, array($this, 'facetCompare')); + + // Asserts that the returned facets are those that we expected. + $this->assertEquals($expected, $category_facets, 'Correct OR facets were returned'); + } + + /** * {@inheritdoc} */ protected function indexItems($index_id) { @@ -103,6 +164,7 @@ class SearchApiSolrTest extends BackendTest { * {@inheritdoc} */ protected function clearIndex() { + /** @var \Drupal\search_api\IndexInterface $index */ $index = Index::load($this->indexId); $index->clear(); // Deleting items take at least 1 second for Solr to parse it so that drupal @@ -151,7 +213,7 @@ class SearchApiSolrTest extends BackendTest { sleep(2); $query = $this->buildSearch(); $results = $query->execute(); - $this->assertEqual($results->getResultCount(), 0, 'Clearing the server worked correctly.'); + $this->assertEquals(0, $results->getResultCount(), 'Clearing the server worked correctly.'); } /**