diff --git a/README.txt b/README.txt index 26ec123..ba25e09 100644 --- a/README.txt +++ b/README.txt @@ -25,6 +25,15 @@ For more detailed documentation, see the handbook [2]. [2] https://drupal.org/node/1999280 +Running the test suite +---------------------- + +This module comes with a suite of automated tests. To execute those, you just +need to have a (correctly configured) Solr instance running at the following +address: + http://localhost:8983/solr/d8 +(This represents a core named "d8" in a default installation of Solr.) + Supported optional features --------------------------- diff --git a/src/Plugin/search_api/backend/SearchApiSolrBackend.php b/src/Plugin/search_api/backend/SearchApiSolrBackend.php index 327445d..03e1786 100644 --- a/src/Plugin/search_api/backend/SearchApiSolrBackend.php +++ b/src/Plugin/search_api/backend/SearchApiSolrBackend.php @@ -531,6 +531,44 @@ public function viewSettings() { /** * {@inheritdoc} */ + public function updateIndex(IndexInterface $index) { + if ($this->indexFieldsUpdated($index)) { + $index->reindex(); + } + } + + /** + * Checks if the recently updated index had any fields changed. + * + * @param \Drupal\search_api\IndexInterface $index + * + * @return bool + */ + protected function indexFieldsUpdated(IndexInterface $index) { + // Get the original index, before the update. If it cannot be found, err on + // the side of caution. + if (!isset($index->original)) { + return TRUE; + } + /** @var \Drupal\search_api\IndexInterface $original */ + $original = $index->original; + + $old_fields = $original->getFields(); + $new_fields = $index->getFields(); + if (!$old_fields && !$new_fields) { + return FALSE; + } + if (array_diff_key($old_fields, $new_fields) || array_diff_key($new_fields, $old_fields)) { + return TRUE; + } + $old_field_names = $this->getFieldNames($original, FALSE, TRUE); + $new_field_names = $this->getFieldNames($index, FALSE, TRUE); + return $old_field_names != $new_field_names; + } + + /** + * {@inheritdoc} + */ public function removeIndex($index) { // Only delete the index's data if the index isn't read-only. if (!is_object($index) || empty($index->read_only)) { @@ -1269,6 +1307,7 @@ protected function extractFacets(QueryInterface $query, Result $resultset) { protected function createFilterQueries(ConditionGroupInterface $conditions, array $solr_fields, array $index_fields) { $or = $conditions->getConjunction() == 'OR'; $fq = array(); + $prefix = ''; foreach ($conditions->getConditions() as $condition) { if ($condition instanceof ConditionInterface) { $field = $condition->getField(); @@ -1282,17 +1321,30 @@ protected function createFilterQueries(ConditionGroupInterface $conditions, arra } else { $q = $this->createFilterQueries($condition, $solr_fields, $index_fields); - if ($conditions->getConjunction() != $condition->getConjunction()) { + if ($conditions->getConjunction() != $condition->getConjunction() && count($q) > 1) { // $or == TRUE means the nested filter has conjunction AND, and vice versa $sep = $or ? ' ' : ' OR '; - $fq[] = count($q) == 1 ? reset($q) : '((' . implode(')' . $sep . '(', $q) . '))'; + $fq[] = '((' . implode(')' . $sep . '(', $q) . '))'; } else { $fq = array_merge($fq, $q); } } } - return ($or && count($fq) > 1) ? array('((' . implode(') OR (', $fq) . '))') : $fq; + foreach ($conditions->getTags() as $tag) { + $prefix = "{!tag=$tag}"; + // We can only apply one tag per filter. + break; + } + if ($or && count($fq) > 1) { + $fq = array('((' . implode(') OR (', $fq) . '))'); + } + if ($prefix) { + foreach ($fq as $i => $filters) { + $fq[$i] = $prefix . $filters; + } + } + return $fq; } /** @@ -1346,8 +1398,6 @@ protected function formatFilterValue($value, $type) { * Helper method for creating the facet field parameters. */ protected function setFacets(array $facets, array $field_names, Query $solarium_query) { - $fq = array(); - if (empty($facets)) { return; } @@ -1358,25 +1408,19 @@ protected function setFacets(array $facets, array $field_names, Query $solarium_ $facet_set->setMinCount(1); $facet_set->setMissing(FALSE); - $taggedFields = array(); foreach ($facets as $info) { if (empty($field_names[$info['field']])) { continue; } - // String fields have their own corresponding facet fields. $field = $field_names[$info['field']]; - // Check for the "or" operator. + // Create the Solarium facet field object. + $facet_field = $facet_set->createFacetField($field)->setField($field); + + // For "OR" facets, add the expected tag for exclusion. 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}"; - // Add the facet field. - $facet_field = $facet_set->createFacetField($field)->setField("{!ex=$escaped}$field"); - } - else { - // Add the facet field. - $facet_field = $facet_set->createFacetField($field)->setField($field); + $facet_field->setExcludes(array('facet:' . $info['field'])); } + // Set limit, unless it's the default. if ($info['limit'] != 10) { $limit = $info['limit'] ? $info['limit'] : -1; @@ -1395,25 +1439,6 @@ protected function setFacets(array $facets, array $field_names, Query $solarium_ $facet_field->setMissing(FALSE); } } - - // Tag filters of fields with "OR" facets. - foreach ($taggedFields as $field => $tag) { - $regex = '#(? $conditions) { - // Solr can't handle two tags on the same filter, so we don't add two. - // Another option here would even be to remove the other tag, too, - // since we can be pretty sure that this filter does not originate from - // a facet – however, wrong results would still be possible, and this is - // definitely an edge case, so don't bother. - if (preg_match($regex, $conditions) && substr($conditions, 0, 6) != '{!tag=') { - $fq[$i] = $tag . $conditions; - } - } - } - - foreach ($fq as $key => $conditions_query) { - $solarium_query->createFilterQuery('facets_' . $key)->setQuery($conditions_query); - } } /** diff --git a/tests/src/Kernel/SearchApiSolrTest.php b/tests/src/Kernel/SearchApiSolrTest.php index df4a70a..3434177 100644 --- a/tests/src/Kernel/SearchApiSolrTest.php +++ b/tests/src/Kernel/SearchApiSolrTest.php @@ -9,8 +9,9 @@ use Drupal\search_api\Entity\Index; use Drupal\search_api\Entity\Server; +use Drupal\search_api\Query\QueryInterface; use Drupal\search_api\Query\ResultSetInterface; -use Drupal\search_api\Utility; +use Drupal\search_api_solr\Plugin\search_api\backend\SearchApiSolrBackend; use Drupal\Tests\search_api_db\Kernel\BackendTest; /** @@ -55,10 +56,10 @@ class SearchApiSolrTest extends BackendTest { public function setUp() { parent::setUp(); // @todo For some reason the init event (see AutoloaderSubscriber) is not - // working in command line tests - $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; + // working in command line tests + $file_path = drupal_get_path('module', 'search_api_solr') . '/vendor/autoload.php'; + if (!class_exists('Solarium\\Client') && ($file_path != DRUPAL_ROOT . '/core/vendor/autoload.php')) { + require_once $file_path; } $this->installConfig(array('search_api_test_solr')); @@ -68,9 +69,8 @@ public function setUp() { \Drupal::service('router.builder')->rebuild(); try { - /** @var \Drupal\search_api\ServerInterface $server */ - $server = Server::load($this->serverId); - if ($server->getBackend()->ping()) { + $backend = Server::load($this->serverId)->getBackend(); + if ($backend instanceof SearchApiSolrBackend && $backend->ping()) { $this->solrAvailable = TRUE; } } @@ -99,59 +99,6 @@ public function testFramework() { } /** - * 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) { @@ -180,10 +127,37 @@ protected function checkServerTables() { // The Solr backend doesn't create any database tables. } + /** + * {@inheritdoc} + */ protected function updateIndex() { // The parent assertions don't make sense for the Solr backend. } + /** + * {@inheritdoc} + */ + protected function checkMultiValuedInfo() { + // We don't keep multi-valued (or any other) field information. + } + + /** + * {@inheritdoc} + */ + protected function editServerPartial($enable = TRUE) { + // There is no "partial matching" option for Solr servers (yet). + } + + /** + * {@inheritdoc} + */ + protected function searchSuccessPartial() { + // There is no "partial matching" option for Solr servers (yet). + } + + /** + * {@inheritdoc} + */ protected function editServer() { // The parent assertions don't make sense for the Solr backend. } @@ -199,14 +173,328 @@ protected function searchSuccess2() { } /** + * Tests various previously fixed bugs, mostly from the Database backend. + * + * Needs to be overridden here since some of the tests don't apply. + */ + protected function regressionTests() { + // Regression tests for #2007872. + $results = $this->buildSearch('test') + ->sort('id', QueryInterface::SORT_ASC) + ->sort('type', QueryInterface::SORT_ASC) + ->execute(); + $this->assertEquals(4, $results->getResultCount(), 'Sorting on field with NULLs returned correct number of results.'); + $this->assertEquals($this->getItemIds(array(1, 2, 3, 4)), array_keys($results->getResultItems()), 'Sorting on field with NULLs returned correct result.'); + $this->assertIgnored($results); + $this->assertWarnings($results); + + $query = $this->buildSearch(); + $conditions = $query->createConditionGroup('OR'); + $conditions->addCondition('id', 3); + $conditions->addCondition('type', 'article'); + $query->addConditionGroup($conditions); + $query->sort('id', QueryInterface::SORT_ASC); + $results = $query->execute(); + $this->assertEquals(3, $results->getResultCount(), 'OR filter on field with NULLs returned correct number of results.'); + $this->assertEquals($this->getItemIds(array(3, 4, 5)), array_keys($results->getResultItems()), 'OR filter on field with NULLs returned correct result.'); + $this->assertIgnored($results); + $this->assertWarnings($results); + + // Regression tests for #1863672. + $query = $this->buildSearch(); + $conditions = $query->createConditionGroup('OR'); + $conditions->addCondition('keywords', 'orange'); + $conditions->addCondition('keywords', 'apple'); + $query->addConditionGroup($conditions); + $query->sort('id', QueryInterface::SORT_ASC); + $results = $query->execute(); + $this->assertEquals(4, $results->getResultCount(), 'OR filter on multi-valued field returned correct number of results.'); + $this->assertEquals($this->getItemIds(array(1, 2, 4, 5)), array_keys($results->getResultItems()), 'OR filter on multi-valued field returned correct result.'); + $this->assertIgnored($results); + $this->assertWarnings($results); + + $query = $this->buildSearch(); + $conditions = $query->createConditionGroup('OR'); + $conditions->addCondition('keywords', 'orange'); + $conditions->addCondition('keywords', 'strawberry'); + $query->addConditionGroup($conditions); + $conditions = $query->createConditionGroup('OR'); + $conditions->addCondition('keywords', 'apple'); + $conditions->addCondition('keywords', 'grape'); + $query->addConditionGroup($conditions); + $query->sort('id', QueryInterface::SORT_ASC); + $results = $query->execute(); + $this->assertEquals(3, $results->getResultCount(), 'Multiple OR filters on multi-valued field returned correct number of results.'); + $this->assertEquals($this->getItemIds(array(2, 4, 5)), array_keys($results->getResultItems()), 'Multiple OR filters on multi-valued field returned correct result.'); + $this->assertIgnored($results); + $this->assertWarnings($results); + + $query = $this->buildSearch(); + $conditions1 = $query->createConditionGroup('OR'); + $conditions = $query->createConditionGroup('AND'); + $conditions->addCondition('keywords', 'orange'); + $conditions->addCondition('keywords', 'apple'); + $conditions1->addConditionGroup($conditions); + $conditions = $query->createConditionGroup('AND'); + $conditions->addCondition('keywords', 'strawberry'); + $conditions->addCondition('keywords', 'grape'); + $conditions1->addConditionGroup($conditions); + $query->addConditionGroup($conditions1); + $query->sort('id', QueryInterface::SORT_ASC); + $results = $query->execute(); + $this->assertEquals(3, $results->getResultCount(), 'Complex nested filters on multi-valued field returned correct number of results.'); + $this->assertEquals($this->getItemIds(array(2, 4, 5)), array_keys($results->getResultItems()), 'Complex nested filters on multi-valued field returned correct result.'); + $this->assertIgnored($results); + $this->assertWarnings($results); + + // Regression tests for #2040543. + $query = $this->buildSearch(); + $facets['category'] = array( + 'field' => 'category', + 'limit' => 0, + 'min_count' => 1, + 'missing' => TRUE, + ); + $query->setOption('search_api_facets', $facets); + $query->range(0, 0); + $results = $query->execute(); + $expected = array( + array('count' => 2, 'filter' => '"article_category"'), + array('count' => 2, 'filter' => '"item_category"'), + array('count' => 1, 'filter' => '!'), + ); + $type_facets = $results->getExtraData('search_api_facets')['category']; + usort($type_facets, array($this, 'facetCompare')); + $this->assertEquals($expected, $type_facets, 'Correct facets were returned'); + + $query = $this->buildSearch(); + $facets['category']['missing'] = FALSE; + $query->setOption('search_api_facets', $facets); + $query->range(0, 0); + $results = $query->execute(); + $expected = array( + array('count' => 2, 'filter' => '"article_category"'), + array('count' => 2, 'filter' => '"item_category"'), + ); + $type_facets = $results->getExtraData('search_api_facets')['category']; + usort($type_facets, array($this, 'facetCompare')); + $this->assertEquals($expected, $type_facets, 'Correct facets were returned'); + + // Regression tests for #2111753. + $keys = array( + '#conjunction' => 'OR', + 'foo', + 'test', + ); + $query = $this->buildSearch($keys, array(), array('name')); + $query->sort('id', QueryInterface::SORT_ASC); + $results = $query->execute(); + $this->assertEquals(3, $results->getResultCount(), 'OR keywords returned correct number of results.'); + $this->assertEquals($this->getItemIds(array(1, 2, 4)), array_keys($results->getResultItems()), 'OR keywords returned correct result.'); + $this->assertIgnored($results); + $this->assertWarnings($results); + + $query = $this->buildSearch($keys, array(), array('name', 'body')); + $query->range(0, 0); + $results = $query->execute(); + $this->assertEquals(5, $results->getResultCount(), 'Multi-field OR keywords returned correct number of results.'); + $this->assertFalse($results->getResultItems(), 'Multi-field OR keywords returned correct result.'); + $this->assertIgnored($results); + $this->assertWarnings($results); + + $keys = array( + '#conjunction' => 'OR', + 'foo', + 'test', + array( + '#conjunction' => 'AND', + 'bar', + 'baz', + ), + ); + $query = $this->buildSearch($keys, array(), array('name')); + $query->sort('id', QueryInterface::SORT_ASC); + $results = $query->execute(); + $this->assertEquals(4, $results->getResultCount(), 'Nested OR keywords returned correct number of results.'); + $this->assertEquals($this->getItemIds(array(1, 2, 4, 5)), array_keys($results->getResultItems()), 'Nested OR keywords returned correct result.'); + $this->assertIgnored($results); + $this->assertWarnings($results); + + $keys = array( + '#conjunction' => 'OR', + array( + '#conjunction' => 'AND', + 'foo', + 'test', + ), + array( + '#conjunction' => 'AND', + 'bar', + 'baz', + ), + ); + $query = $this->buildSearch($keys, array(), array('name', 'body')); + $query->sort('id', QueryInterface::SORT_ASC); + $results = $query->execute(); + $this->assertEquals(4, $results->getResultCount(), 'Nested multi-field OR keywords returned correct number of results.'); + $this->assertEquals($this->getItemIds(array(1, 2, 4, 5)), array_keys($results->getResultItems()), 'Nested multi-field OR keywords returned correct result.'); + $this->assertIgnored($results); + $this->assertWarnings($results); + + // Regression tests for #2127001. + $keys = array( + '#conjunction' => 'AND', + '#negation' => TRUE, + 'foo', + 'bar', + ); + $results = $this->buildSearch($keys) + ->sort('search_api_id', QueryInterface::SORT_ASC) + ->execute(); + $this->assertEquals(2, $results->getResultCount(), 'Negated AND fulltext search returned correct number of results.'); + $this->assertEquals($this->getItemIds(array(3, 4)), array_keys($results->getResultItems()), 'Negated AND fulltext search returned correct result.'); + $this->assertIgnored($results); + $this->assertWarnings($results); + + $keys = array( + '#conjunction' => 'OR', + '#negation' => TRUE, + 'foo', + 'baz', + ); + $results = $this->buildSearch($keys)->execute(); + $this->assertEquals(1, $results->getResultCount(), 'Negated OR fulltext search returned correct number of results.'); + $this->assertEquals($this->getItemIds(array(3)), array_keys($results->getResultItems()), 'Negated OR fulltext search returned correct result.'); + $this->assertIgnored($results); + $this->assertWarnings($results); + + $keys = array( + '#conjunction' => 'AND', + 'test', + array( + '#conjunction' => 'AND', + '#negation' => TRUE, + 'foo', + 'bar', + ), + ); + $results = $this->buildSearch($keys) + ->sort('search_api_id', QueryInterface::SORT_ASC) + ->execute(); + $this->assertEquals(2, $results->getResultCount(), 'Nested NOT AND fulltext search returned correct number of results.'); + $this->assertEquals($this->getItemIds(array(3, 4)), array_keys($results->getResultItems()), 'Nested NOT AND fulltext search returned correct result.'); + $this->assertIgnored($results); + $this->assertWarnings($results); + + // Regression tests for #2136409. + $query = $this->buildSearch(); + $query->addCondition('category', NULL); + $query->sort('search_api_id', QueryInterface::SORT_ASC); + $results = $query->execute(); + $this->assertEquals(1, $results->getResultCount(), 'NULL filter returned correct number of results.'); + $this->assertEquals($this->getItemIds(array(3)), array_keys($results->getResultItems()), 'NULL filter returned correct result.'); + + $query = $this->buildSearch(); + $query->addCondition('category', NULL, '<>'); + $query->sort('search_api_id', QueryInterface::SORT_ASC); + $results = $query->execute(); + $this->assertEquals(4, $results->getResultCount(), 'NOT NULL filter returned correct number of results.'); + $this->assertEquals($this->getItemIds(array(1, 2, 4, 5)), array_keys($results->getResultItems()), 'NOT NULL filter returned correct result.'); + + // Regression tests for #1658964. + $query = $this->buildSearch(); + $facets['type'] = array( + 'field' => 'type', + 'limit' => 0, + 'min_count' => 0, + 'missing' => TRUE, + ); + $query->setOption('search_api_facets', $facets); + $query->addCondition('type', 'article'); + $query->range(0, 0); + $results = $query->execute(); + $expected = array( + array('count' => 2, 'filter' => '"article"'), + array('count' => 0, 'filter' => '!'), + array('count' => 0, 'filter' => '"item"'), + ); + $facets = $results->getExtraData('search_api_facets', array())['type']; + usort($facets, array($this, 'facetCompare')); + $this->assertEquals($expected, $facets, 'Correct facets were returned'); + + // Regression tests for #2469547. + $query = $this->buildSearch(); + $facets = array(); + $facets['body'] = array( + 'field' => 'body', + 'limit' => 0, + 'min_count' => 1, + 'missing' => FALSE, + ); + $query->setOption('search_api_facets', $facets); + $query->addCondition('id', 5, '<>'); + $query->range(0, 0); + $results = $query->execute(); + $expected = array( + array('count' => 4, 'filter' => '"test"'), + array('count' => 3, 'filter' => '"case"'), + array('count' => 1, 'filter' => '"bar"'), + array('count' => 1, 'filter' => '"foobar"'), + ); + // We can't guarantee the order of returned facets, since "bar" and "foobar" + // both occur once, so we have to manually sort the returned facets first. + $facets = $results->getExtraData('search_api_facets', array())['body']; + usort($facets, array($this, 'facetCompare')); + $this->assertEquals($expected, $facets, 'Correct facets were returned for a fulltext field.'); + + // Regression tests for #1403916. + $query = $this->buildSearch('test foo'); + $facets = array(); + $facets['type'] = array( + 'field' => 'type', + 'limit' => 0, + 'min_count' => 1, + 'missing' => TRUE, + ); + $query->setOption('search_api_facets', $facets); + $query->range(0, 0); + $results = $query->execute(); + $expected = array( + array('count' => 2, 'filter' => '"item"'), + array('count' => 1, 'filter' => '"article"'), + ); + $facets = $results->getExtraData('search_api_facets', array())['type']; + usort($facets, array($this, 'facetCompare')); + $this->assertEquals($expected, $facets, 'Correct facets were returned'); + + // Regression tests for #2557291. + $results = $this->buildSearch('smile' . json_decode('"\u1F601"')) + ->execute(); + $this->assertEquals(1, $results->getResultCount(), 'Search for keywords with umlauts returned correct number of results.'); + $this->assertEquals($this->getItemIds(array(1)), array_keys($results->getResultItems()), 'Search for keywords with umlauts returned correct result.'); + $this->assertIgnored($results); + $this->assertWarnings($results); + + $results = $this->buildSearch() + ->addCondition('keywords', 'grape', '<>') + ->execute(); + $this->assertEquals(2, $results->getResultCount(), 'Negated filter on multi-valued field returned correct number of results.'); + $this->assertEquals($this->getItemIds(array(1, 3)), array_keys($results->getResultItems()), 'Negated filter on multi-valued field returned correct result.'); + $this->assertIgnored($results); + $this->assertWarnings($results); + } + + /** * {@inheritdoc} */ protected function checkModuleUninstall() { // See whether clearing the server works. // Regression test for #2156151. + /** @var \Drupal\search_api\ServerInterface $server */ $server = Server::load($this->serverId); + /** @var \Drupal\search_api\IndexInterface $index */ $index = Index::load($this->indexId); - $server->deleteAllItems($index); + $server->deleteAllIndexItems($index); // Deleting items take at least 1 second for Solr to parse it so that drupal // doesn't get timeouts while waiting for Solr. Lets give it 2 seconds to // make sure we are in bounds.