diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 74905a8..d382f21 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,7 +1,5 @@ Search API 1.x, dev (xx/xx/xxxx): --------------------------------- -- #1956650 by drunken monkey, wwhurley: Fixed trackItemChange not checking for - empty $item_ids. - #2100191 by drunken monkey, Bojhan: Added an admin description to the Search API landing page. diff --git a/README.txt b/README.txt index 9ea75ed..6ea6252 100644 --- a/README.txt +++ b/README.txt @@ -210,12 +210,6 @@ search_api_index_worker_callback_runtime: API will spend indexing (for all indexes combined) in each cron run. The default is 15 seconds. -search_api_batch_per_cron: - By changing this variable, you can define how many batch items are created on - a single cron run. The value is per index, so on a site with 5 indexes with a - cron limit of 100 each, the default value of 10 will load and queue up to 5000 - search items in up to 50 batch items. - Information for developers -------------------------- diff --git a/includes/datasource.inc b/includes/datasource.inc index 8646f6a..a515d66 100644 --- a/includes/datasource.inc +++ b/includes/datasource.inc @@ -160,7 +160,9 @@ interface SearchApiDataSourceControllerInterface { * @param array $indexes * The indexes for which the change should be tracked. * @param $dequeue - * If set to TRUE, also change the status of queued items. + * (deprecated) If set to TRUE, also change the status of queued items. + * The concept of queued items will be removed in the Drupal 8 version of + * this module. * * @throws SearchApiDataSourceException * If any of the indexes doesn't use the same item type as this controller. @@ -180,7 +182,12 @@ interface SearchApiDataSourceControllerInterface { * The index for which the items were queued. * * @throws SearchApiDataSourceException - * If any of the indexes doesn't use the same item type as this controller. + * If the index doesn't use the same item type as this controller. + * + * @deprecated + * As of Search API 1.10, the cron queue is not used for indexing anymore, + * therefore this method has become useless. It will be removed in the + * Drupal 8 version of this module. */ public function trackItemQueued($item_ids, SearchApiIndex $index); @@ -189,7 +196,7 @@ interface SearchApiDataSourceControllerInterface { * * @param array $item_ids * The IDs of the indexed items. - * @param SearchApiIndex $indexes + * @param SearchApiIndex $index * The index on which the items were indexed. * * @throws SearchApiDataSourceException @@ -571,24 +578,10 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou } /** - * Set the tracking status of the given items to "changed"/"dirty". - * - * Unless $dequeue is set to TRUE, this operation is ignored for items whose - * status is not "indexed". - * - * @param $item_ids - * Either an array with the IDs of the changed items. Or FALSE to mark all - * items as changed for the given indexes. - * @param array $indexes - * The indexes for which the change should be tracked. - * @param $dequeue - * If set to TRUE, also change the status of queued items. - * - * @throws SearchApiDataSourceException - * If any of the indexes doesn't use the same item type as this controller. + * {@inheritdoc} */ public function trackItemChange($item_ids, array $indexes, $dequeue = FALSE) { - if (!$this->table || $item_ids === array()) { + if (!$this->table) { return; } $index_ids = array(); @@ -609,22 +602,11 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou } /** - * Set the tracking status of the given items to "queued". - * - * Queued items are not marked as "dirty" even when they are changed, and they - * are not returned by the getChangedItems() method. - * - * @param $item_ids - * Either an array with the IDs of the queued items. Or FALSE to mark all - * items as queued for the given indexes. - * @param SearchApiIndex $index - * The index for which the items were queued. - * - * @throws SearchApiDataSourceException - * If any of the indexes doesn't use the same item type as this controller. + * {@inheritdoc} */ public function trackItemQueued($item_ids, SearchApiIndex $index) { - if (!$this->table || $item_ids === array()) { + $this->checkIndex($index); + if (!$this->table) { return; } $update = db_update($this->table) diff --git a/includes/index_entity.inc b/includes/index_entity.inc index d36b2f8..dc45666 100644 --- a/includes/index_entity.inc +++ b/includes/index_entity.inc @@ -230,7 +230,6 @@ class SearchApiIndex extends Entity { */ public function dequeueItems() { $this->datasource()->stopTracking(array($this)); - _search_api_empty_cron_queue($this); } /** diff --git a/search_api.install b/search_api.install index 8a3366c..f39608c 100644 --- a/search_api.install +++ b/search_api.install @@ -330,7 +330,6 @@ function search_api_disable() { // Modules defining entity or item types might have been disabled. Ignore. } } - DrupalQueue::get('search_api_indexing_queue')->deleteQueue(); } /** @@ -813,3 +812,17 @@ function search_api_update_7114() { } } } + +/** + * Switch to indexing without the use of a cron queue. + */ +function search_api_update_7115() { + variable_del('search_api_batch_per_cron'); + DrupalQueue::get('search_api_indexing_queue')->deleteQueue(); + db_update('search_api_item') + ->fields(array( + 'changed' => 1, + )) + ->condition('changed', 0, '<') + ->execute(); +} diff --git a/search_api.module b/search_api.module index 395a230..2c66198 100644 --- a/search_api.module +++ b/search_api.module @@ -265,51 +265,66 @@ function search_api_permission() { * Will index $options['cron-limit'] items for each enabled index. */ function search_api_cron() { - $queue = DrupalQueue::get('search_api_indexing_queue'); - foreach (search_api_index_load_multiple(FALSE, array('enabled' => TRUE, 'read_only' => 0)) as $index) { - $limit = isset($index->options['cron_limit']) + // Load all enabled, not read-only indexes. + $conditions = array( + 'enabled' => TRUE, + 'read_only' => 0 + ); + $indexes = search_api_index_load_multiple(FALSE, $conditions); + if (!$indexes) { + return; + } + // Remember servers which threw an exception. + $ignored_servers = array(); + // Continue indexing, one batch from each index, until the time is up, but at + // least index one batch per index. + $end = time() + variable_get('search_api_index_worker_callback_runtime', 15); + $first_pass = TRUE; + while (TRUE) { + if (!$indexes) { + break; + } + foreach ($indexes as $id => $index) { + if (!$first_pass && time() >= $end) { + break 2; + } + if (!empty($ignored_servers[$index->server])) { + continue; + } + + $limit = isset($index->options['cron_limit']) ? $index->options['cron_limit'] : SEARCH_API_DEFAULT_CRON_LIMIT; - if ($limit) { - try { - $task = array('index' => $index->machine_name); - // Fetch items to index, do not fetch more than the configured amount - // of batches to be created per cron run to avoid timeouts. - $ids = search_api_get_items_to_index($index, $limit > 0 ? $limit * variable_get('search_api_batch_per_cron', 10) : -1); - if (!$ids) { - continue; + $num = 0; + if ($limit) { + try { + $num = search_api_index_items($index, $limit); + if ($num) { + $variables = array( + '@num' => $num, + '%name' => $index->name + ); + watchdog('search_api', 'Indexed @num items for index %name.', $variables, WATCHDOG_INFO); + } } - $batches = $limit > 0 ? array_chunk($ids, $limit, TRUE) : array($ids); - foreach ($batches as $batch) { - $task['items'] = $batch; - $queue->createItem($task); + catch (SearchApiException $e) { + // Exceptions will probably be caused by the server in most cases. + // Therefore, don't index for any index on this server. + $ignored_servers[$index->server] = TRUE; + watchdog_exception('search_api', $e); } - // Mark items as queued so they won't be inserted into the queue again - // on the next cron run. - search_api_track_item_queued($index, $ids); } - catch (SearchApiException $e) { - watchdog_exception('search_api', $e); + if (!$num) { + // Couldn't index any items => stop indexing for this index in this + // cron run. + unset($indexes[$id]); } } + $first_pass = FALSE; } } /** - * Implements hook_cron_queue_info(). - * - * Defines a queue for saved searches that should be checked for new items. - */ -function search_api_cron_queue_info() { - return array( - 'search_api_indexing_queue' => array( - 'worker callback' => '_search_api_indexing_queue_process', - 'time' => variable_get('search_api_index_worker_callback_runtime', 15), - ), - ); -} - -/** * Implements hook_entity_info(). */ function search_api_entity_info() { @@ -696,15 +711,6 @@ function search_api_search_api_index_update(SearchApiIndex $index) { $index->queueItems(); } } - - // If the cron batch size changed, empty the cron queue for this index. - $old_cron = $index->original->options + array('cron_limit' => NULL); - $old_cron = $old_cron['cron_limit']; - $new_cron = $index->options + array('cron_limit' => NULL); - $new_cron = $new_cron['cron_limit']; - if ($old_cron !== $new_cron) { - _search_api_empty_cron_queue($index, TRUE); - } } /** @@ -1142,6 +1148,12 @@ function search_api_track_item_change($type, array $item_ids) { * The index on which items were queued. * @param array $item_ids * The ids of the queued items. + * + * @deprecated + * As of Search API 1.10, the cron queue is not used for indexing anymore, + * therefore this function has become useless. It will, along with + * SearchApiDataSourceControllerInterface::trackItemQueued(), be removed in + * the Drupal 8 version of this module. */ function search_api_track_item_queued(SearchApiIndex $index, array $item_ids) { $index->datasource()->trackItemQueued($item_ids, $index); @@ -1278,53 +1290,31 @@ function _search_api_settings_equals($setting1, $setting2) { } /** - * Indexes items for the specified index. Only items marked as changed are - * indexed, in their order of change (if known). + * Indexes items for the specified index. + * + * Only items marked as changed are indexed, in their order of change (if + * known). * * @param SearchApiIndex $index * The index on which items should be indexed. - * @param $limit - * The number of items which should be indexed at most. -1 means no limit. + * @param int $limit + * (optional) The number of items which should be indexed at most. Defaults to + * -1, which means that all changed items should be indexed. + * + * @return int + * Number of successfully indexed items. * * @throws SearchApiException * If any error occurs during indexing. - * - * @return - * Number of successfully indexed items. */ function search_api_index_items(SearchApiIndex $index, $limit = -1) { - // Don't try to index read-only indexes. + // Don't try to index on read-only indexes. if ($index->read_only) { return 0; } - $queue = DrupalQueue::get('search_api_indexing_queue'); - $queue->createQueue(); - $indexed = 0; - $unlimited = $limit < 0; - $release_items = array(); - while (($unlimited || $indexed < $limit) && ($item = $queue->claimItem(30))) { - if ($item->data['index'] === $index->machine_name) { - $indexed += _search_api_indexing_queue_process($item->data); - $queue->deleteItem($item); - } - else { - $release_items[] = $item; - } - } - - foreach ($release_items as $item) { - $queue->releaseItem($item); - } - - if ($unlimited || $indexed < $limit) { - $ids = search_api_get_items_to_index($index, $unlimited ? -1 : $limit - $indexed); - if ($ids) { - $indexed += count(search_api_index_specific_items($index, $ids)); - } - } - - return $indexed; + $ids = search_api_get_items_to_index($index, $limit); + return $ids ? count(search_api_index_specific_items($index, $ids)) : 0; } /** @@ -1337,11 +1327,11 @@ function search_api_index_items(SearchApiIndex $index, $limit = -1) { * @param array $ids * The IDs of the items which should be indexed. * + * @return array + * The IDs of all successfully indexed items. + * * @throws SearchApiException * If any error occurs during indexing. - * - * @return - * The IDs of all successfully indexed items. */ function search_api_index_specific_items(SearchApiIndex $index, array $ids) { $items = $index->loadItems($ids); @@ -2475,42 +2465,6 @@ function search_api_index_reindex($id) { */ function _search_api_index_reindex(SearchApiIndex $index) { $index->datasource()->trackItemChange(FALSE, array($index), TRUE); - _search_api_empty_cron_queue($index); -} - -/** - * Helper method for removing all of an index's jobs from the cron queue. - * - * @param SearchApiIndex $index - * The index whose jobs should be removed. - * @param $mark_changed - * If TRUE, mark all items in the queue as "changed" again. Defaults to FALSE. - */ -function _search_api_empty_cron_queue(SearchApiIndex $index, $mark_changed = FALSE) { - $index_id = $index->machine_name; - $queue = DrupalQueue::get('search_api_indexing_queue'); - $queue->createQueue(); - $ids = array(); - $release_items = array(); - while ($item = $queue->claimItem()) { - if ($item->data['index'] === $index_id) { - $queue->deleteItem($item); - if ($mark_changed) { - $ids = array_merge($ids, $item->data['items']); - } - } - else { - $release_items[] = $item; - } - } - - foreach ($release_items as $item) { - $queue->releaseItem($item); - } - - if ($ids) { - $index->datasource()->trackItemChange($ids, array($index), TRUE); - } } /** @@ -2563,42 +2517,6 @@ function search_api_index_options_list() { } /** - * Cron queue worker callback for indexing some items. - * - * @param array $task - * An associative array containing: - * - index: The ID of the index on which items should be indexed. - * - items: The items that should be indexed. - * - * @return - * The number of successfully indexed items. - */ -function _search_api_indexing_queue_process(array $task) { - $index = search_api_index_load($task['index']); - try { - if ($index && $index->enabled && !$index->read_only && $task['items']) { - $indexed = search_api_index_specific_items($index, $task['items']); - $num = count($indexed); - // If some items couldn't be indexed, mark them as dirty again. - if ($num < count($task['items'])) { - // Believe it or not but this is actually quite faster than the equivalent - // $diff = array_diff($task['items'], $indexed); - $diff = array_keys(array_diff_key(array_flip($task['items']), array_flip($indexed))); - // Mark the items as dirty again. - $index->datasource()->trackItemChange($diff, array($index), TRUE); - } - if ($num) { - watchdog('search_api', t('Indexed @num items for index @name', array('@num' => $num, '@name' => $index->name)), NULL, WATCHDOG_INFO); - } - return $num; - } - } - catch (SearchApiException $e) { - watchdog_exception('search_api', $e); - } -} - -/** * Shutdown function which indexes all queued items, if any. */ function _search_api_index_queued_items() { diff --git a/search_api.test b/search_api.test index 1748627..f8a506b 100644 --- a/search_api.test +++ b/search_api.test @@ -1,29 +1,66 @@ assertResponse(200, 'HTTP code 200 returned.'); return $ret; } + /** + * Overrides DrupalWebTestCase::drupalPost(). + * + * Additionally asserts that the HTTP request returned a 200 status code. + */ protected function drupalPost($path, $edit, $submit, array $options = array(), array $headers = array(), $form_html_id = NULL, $extra_post = NULL) { $ret = parent::drupalPost($path, $edit, $submit, $options, $headers, $form_html_id, $extra_post); $this->assertResponse(200, 'HTTP code 200 returned.'); return $ret; } + /** + * Returns information about this test case. + * + * @return array + * An array with information about this test case. + */ public static function getInfo() { return array( 'name' => 'Test search API framework', @@ -32,24 +69,34 @@ class SearchApiWebTest extends DrupalWebTestCase { ); } + /** + * {@inheritdoc} + */ public function setUp() { parent::setUp('entity', 'search_api', 'search_api_test'); } + /** + * Tests correct admin UI, indexing and search behavior. + * + * We only use a single test method to avoid wasting ressources on setting up + * the test environment multiple times. This will be the only method called + * by the Simpletest framework (since the method name starts with "test"). It + * in turn calls other methdos that set up the environment in a certain way + * and then run tests on it. + */ public function testFramework() { $this->drupalLogin($this->drupalCreateUser(array('administer search_api'))); - // @todo Why is there no default index? - //$this->deleteDefaultIndex(); $this->insertItems(); - $this->checkOverview1(); $this->createIndex(); - $this->insertItems(5); + $this->insertItems(); $this->createServer(); - $this->checkOverview2(); + $this->checkOverview(); $this->enableIndex(); $this->searchNoResults(); $this->indexItems(); $this->searchSuccess(); + $this->checkIndexingOrder(); $this->editServer(); $this->clearIndex(); $this->searchNoResults(); @@ -57,57 +104,44 @@ class SearchApiWebTest extends DrupalWebTestCase { $this->disableModules(); } - protected function deleteDefaultIndex() { - $this->drupalPost('admin/config/search/search_api/index/default_node_index/delete', array(), t('Confirm')); - } - - protected function insertItems($offset = 0) { + /** + * Inserts some test items into the database, via the test module. + * + * @param int $number + * The number of items to insert. + * + * @see insertItem() + */ + protected function insertItems($number = 5) { $count = db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField(); - $this->insertItem(array( - 'id' => $offset + 1, - 'title' => 'Title 1', - 'body' => 'Body text 1.', - 'type' => 'Item', - )); - $this->insertItem(array( - 'id' => $offset + 2, - 'title' => 'Title 2', - 'body' => 'Body text 2.', - 'type' => 'Item', - )); - $this->insertItem(array( - 'id' => $offset + 3, - 'title' => 'Title 3', - 'body' => 'Body text 3.', - 'type' => 'Item', - )); - $this->insertItem(array( - 'id' => $offset + 4, - 'title' => 'Title 4', - 'body' => 'Body text 4.', - 'type' => 'Page', - )); - $this->insertItem(array( - 'id' => $offset + 5, - 'title' => 'Title 5', - 'body' => 'Body text 5.', - 'type' => 'Page', - )); + for ($i = 1; $i <= $number; ++$i) { + $id = $count + $i; + $this->insertItem(array( + 'id' => $id, + 'title' => "Title $id", + 'body' => "Body text $id.", + 'type' => 'Item', + )); + } $count = db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField() - $count; - $this->assertEqual($count, 5, '5 items successfully inserted.'); + $this->assertEqual($count, $number, "$number items successfully inserted."); } - protected function insertItem($values) { + /** + * Helper function for inserting a single test item. + * + * @param array $values + * The property values of the test item. + * + * @see search_api_test_insert_item() + */ + protected function insertItem(array $values) { $this->drupalPost('search_api_test/insert', $values, t('Save')); } - protected function checkOverview1() { - // This test fails for no apparent reason for drupal.org test bots. - // Commenting them out for now. - //$this->drupalGet('admin/config/search/search_api'); - //$this->assertText(t('There are no search servers or indexes defined yet.'), '"No servers" message is displayed.'); - } - + /** + * Creates a test index via the UI and tests whether this works correctly. + */ protected function createIndex() { $values = array( 'name' => '', @@ -222,6 +256,9 @@ class SearchApiWebTest extends DrupalWebTestCase { $this->assertText(t('The index is currently disabled.'), '"Disabled" status displayed.'); } + /** + * Creates a test server via the UI and tests whether this works correctly. + */ protected function createServer() { $values = array( 'name' => '', @@ -264,13 +301,19 @@ class SearchApiWebTest extends DrupalWebTestCase { $this->assertText('search_api_test foo bar', 'Service options displayed.'); } - protected function checkOverview2() { + /** + * Checks whether the server and index are correctly listed in the overview. + */ + protected function checkOverview() { $this->drupalGet('admin/config/search/search_api'); $this->assertText('Search API test server', 'Server displayed.'); $this->assertText('Search API test index', 'Index displayed.'); $this->assertNoText(t('There are no search servers or indexes defined yet.'), '"No servers" message not displayed.'); } + /** + * Moves the index onto the server and enables it. + */ protected function enableIndex() { $values = array( 'server' => $this->server_id, @@ -283,24 +326,61 @@ class SearchApiWebTest extends DrupalWebTestCase { $this->assertText(t('The index was successfully enabled.')); } + /** + * Asserts that a search on the index works but yields no results. + * + * This is the case since no items should have been indexed yet. + */ protected function searchNoResults() { - $this->drupalGet('search_api_test/query/' . $this->index_id); - $this->assertText('result count = 0', 'No search results returned without indexing.'); - $this->assertText('results = ()', 'No search results returned without indexing.'); + $results = $this->doSearch(); + $this->assertEqual($results['result count'], 0, 'No search results returned without indexing.'); + $this->assertEqual(array_keys($results['results']), array(), 'No search results returned without indexing.'); + } + + /** + * Executes a search on the test index. + * + * Helper method used for testing search results. + * + * @param int|null $offset + * (optional) The offset for the returned results. + * @param int|null $limit + * (optional) The limit for the returned results. + * + * @return array + * Search results as specified by SearchApiQueryInterface::execute(). + */ + protected function doSearch($offset = NULL, $limit = NULL) { + // Since we change server and index settings via the UI (and, therefore, in + // different page requests), the static cache in this page request + // (executing the tests) will get stale. Therefore, we clear it before + // executing the search. + search_api_index_load($this->index_id, TRUE); + search_api_server_load($this->server_id, TRUE); + + $query = search_api_query($this->index_id); + if ($offset || $limit) { + $query->range($offset, $limit); + } + return $query->execute(); } + /** + * Tests indexing via the UI "Index now" functionality. + * + * Asserts that errors during indexing are handled properly and that the + * status readings work. + */ protected function indexItems() { - $this->drupalGet("admin/config/search/search_api/index/{$this->index_id}/status"); + $this->checkIndexStatus(); $this->assertText(t('The index is currently enabled.'), '"Enabled" status displayed.'); - $this->assertText(t('All items still need to be indexed (@total total).', array('@total' => 10)), 'Correct index status displayed.'); - $this->assertText(t('Index now'), '"Index now" button found.'); - $this->assertText(t('Clear index'), '"Clear index" button found.'); - $this->assertNoText(t('Re-index content'), '"Re-index" button not found.'); // Here we test the indexing + the warning message when some items - // can not be indexed. - // The server refuses (for test purpose) to index items with IDs that are - // multiples of 8 unless the "search_api_test_index_all" variable is set. + // cannot be indexed. + // The server refuses (for test purpose) to index the item that has the same + // ID as the "search_api_test_indexing_break" variable (default: 8). + // Therefore, if we try to index 8 items, only the first seven will be + // successfully indexed and a warning should be displayed. $values = array( 'limit' => 8, ); @@ -308,11 +388,14 @@ class SearchApiWebTest extends DrupalWebTestCase { $this->assertText(t('Successfully indexed @count items.', array('@count' => 7))); $this->assertText(t('1 item could not be indexed. Check the logs for details.'), 'Index errors warning is displayed.'); $this->assertNoText(t("Couldn't index items. Check the logs for details."), "Index error isn't displayed."); - $this->assertText(t('About @percentage% of all items have been indexed in their latest version (@indexed / @total).', array('@indexed' => 7, '@total' => 10, '@percentage' => 70)), 'Correct index status displayed.'); - $this->assertText(t('Re-indexing'), '"Re-index" button found.'); + $this->checkIndexStatus(7); // Here we're testing the error message when no item could be indexed. - // The item with ID 8 is still not indexed. + // The item with ID 8 is still not indexed, but it will be the first to be + // indexed now. Therefore, if we try to index a single items, only item 8 + // will be passed to the server, which will reject it and no items will be + // indexed. Since normally this signifies a more serious error than when + // only some items couldn't be indexed, this is handled differently. $values = array( 'limit' => 1, ); @@ -321,8 +404,10 @@ class SearchApiWebTest extends DrupalWebTestCase { $this->assertNoText(t('1 item could not be indexed. Check the logs for details.'), "Index errors warning isn't displayed."); $this->assertText(t("Couldn't index items. Check the logs for details."), 'Index error is displayed.'); - // Here we test the indexing of all the remaining items. - variable_set('search_api_test_index_all', TRUE); + // No we set the "search_api_test_indexing_break" variable to 0, so all + // items will be indexed. The remaining items (8, 9, 10) should therefore + // be successfully indexed and no warning should show. + variable_set('search_api_test_indexing_break', 0); $values = array( 'limit' => -1, ); @@ -330,20 +415,180 @@ class SearchApiWebTest extends DrupalWebTestCase { $this->assertText(t('Successfully indexed @count items.', array('@count' => 3))); $this->assertNoText(t("Some items couldn't be indexed. Check the logs for details."), "Index errors warning isn't displayed."); $this->assertNoText(t("Couldn't index items. Check the logs for details."), "Index error isn't displayed."); - $this->assertText(t('All items have been indexed (@indexed / @total).', array('@indexed' => 10, '@total' => 10)), 'Correct index status displayed.'); - $this->assertNoText(t('Index now'), '"Index now" button no longer displayed.'); + $this->checkIndexStatus(10); + + // Reset the static cache for the server. + search_api_server_load($this->server_id, TRUE); + } + + /** + * Checks whether the index's "Status" tab shows the correct values. + * + * Helper method used by indexItems() and others. + * + * The internal browser will point to the index's "Status" tab after this + * method is called. + * + * @param int $indexed + * (optional) The number of items that should be indexed at the moment. + * Defaults to 0. + * @param int $total + * (optional) The (correct) total number of items. Defaults to 10. + * @param bool $check_buttons + * (optional) Whether to check for the correct presence/absence of buttons. + * Defaults to TRUE. + */ + protected function checkIndexStatus($indexed = 0, $total = 10, $check_buttons = TRUE) { + $url = "admin/config/search/search_api/index/{$this->index_id}/status"; + if (strpos($this->url, $url) === FALSE) { + $this->drupalGet($url); + } + $all = ($indexed == $total); + $correct_status = 'Correct index status displayed.'; + if ($all) { + $this->assertText(t('All items have been indexed (@total / @total).', array('@total' => $total)), $correct_status); + } + elseif (!$indexed) { + $this->assertText(t('All items still need to be indexed (@total total).', array('@total' => $total)), $correct_status); + } + else { + $percentage = (int) (100 * $indexed / $total); + $text = t('About @percentage% of all items have been indexed in their latest version (@indexed / @total).', + array( + '@indexed' => $indexed, + '@total' => $total, + '@percentage' => $percentage + )); + $this->assertText($text, $correct_status); + } + + if (!$check_buttons) { + return; + } + + if ($all) { + $this->assertNoText(t('Index now'), '"Index now" form not displayed.'); + } + else { + $this->assertText(t('Index now'), '"Index now" form displayed.'); + } + if ($indexed) { + $this->assertText(t('Re-indexing'), '"Re-indexing" form displayed.'); + } + else { + $this->assertNoText(t('Re-indexing'), '"Re-indexing" form not displayed.'); + } + $this->assertText(t('Clear index'), '"Clear index" form displayed.'); } + /** + * Tests whether searches yield the right results after indexing. + * + * The test server only implements range functionality, no kind of fulltext + * search capabilities, so we can only test for that. + */ protected function searchSuccess() { - $this->drupalGet('search_api_test/query/' . $this->index_id); - $this->assertText('result count = 10', 'Correct search result count returned after indexing.'); - $this->assertText('results = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)', 'Correct search results returned after indexing.'); + $results = $this->doSearch(); + $this->assertEqual($results['result count'], 10, 'Correct search result count returned after indexing.'); + $this->assertEqual(array_keys($results['results']), array(1, 2, 3, 4, 5, 6, 7, 8, 9, 10), 'Correct search results returned after indexing.'); + + $results = $this->doSearch(2, 4); + $this->assertEqual($results['result count'], 10, 'Correct search result count with ranged query.'); + $this->assertEqual(array_keys($results['results']), array(3, 4, 5, 6), 'Correct search results with ranged query.'); + } - $this->drupalGet('search_api_test/query/' . $this->index_id . '/foo/2/4'); - $this->assertText('result count = 10', 'Correct search result count with ranged query.'); - $this->assertText('results = (3, 4, 5, 6)', 'Correct search results with ranged query.'); + /** + * Tests whether items are indexed in the right order. + * + * The indexing order should always be that new items are indexed before + * changed ones, and only then the changed items in the order of their change. + * + * This method also assures that this behavior is even observed when indexing + * temporarily fails. + * + * @see https://drupal.org/node/2115127 + */ + protected function checkIndexingOrder() { + // Set cron batch size to 1 so not all items will get indexed right away. + // This also ensures that later, when indexing of a single item will be + // rejected by using the "search_api_test_indexing_break" variable, this + // will have the effect of rejecting "all" items of a batch (since that + // batch only consists of a single item). + $values = array( + 'options[cron_limit]' => 1, + ); + $this->drupalPost("admin/config/search/search_api/index/{$this->index_id}/edit", $values, t('Save settings')); + $this->assertText(t('The search index was successfully edited.')); + + // Manually clear the server's item storage – that way, the items will still + // count as indexed for the Search API, but won't be returned in searches. + // We do this so we have finer-grained control over the order in which items + // are indexed. + search_api_server_load($this->server_id, TRUE)->deleteItems(); + $results = $this->doSearch(); + $this->assertEqual($results['result count'], 0, 'Indexed items were successfully deleted from the server.'); + $this->assertEqual(array_keys($results['results']), array(), 'Indexed items were successfully deleted from the server.'); + + // Now insert some new items, and mark others as changed. Make sure that + // each action has a unique timestamp, so the order will be correct. + $this->drupalGet('search_api_test/touch/8'); + $this->insertItems(1);// item 11 + sleep(1); + $this->drupalGet('search_api_test/touch/2'); + $this->insertItems(1);// item 12 + sleep(1); + $this->drupalGet('search_api_test/touch/5'); + $this->insertItems(1);// item 13 + sleep(1); + $this->drupalGet('search_api_test/touch/8'); + $this->insertItems(1); // item 14 + + // Check whether the status display is right. + $this->checkIndexStatus(7, 14, FALSE); + + // Indexing order should now be: 11, 12, 13, 14, 8, 2, 4. Let's try it out! + // First manually index one item, and see if it's 11. + $values = array( + 'limit' => 1, + ); + $this->drupalPost(NULL, $values, t('Index now')); + $this->assertText(t('Successfully indexed @count item.', array('@count' => 1))); + $this->assertNoText(t("Some items couldn't be indexed. Check the logs for details."), "Index errors warning isn't displayed."); + $this->assertNoText(t("Couldn't index items. Check the logs for details."), "Index error isn't displayed."); + $this->checkIndexStatus(8, 14, FALSE); + + $results = $this->doSearch(); + $this->assertEqual($results['result count'], 1, 'Indexing order test 1: correct result count.'); + $this->assertEqual(array_keys($results['results']), array(11), 'Indexing order test 1: correct results.'); + + // Now index with a cron run, but stop at item 8. + variable_set('search_api_test_indexing_break', 8); + $this->cronRun(); + // Now just the four new items should have been indexed. + $results = $this->doSearch(); + $this->assertEqual($results['result count'], 4, 'Indexing order test 2: correct result count.'); + $this->assertEqual(array_keys($results['results']), array(11, 12, 13, 14), 'Indexing order test 2: correct results.'); + + // This time stop at item 5 (should be the last one). + variable_set('search_api_test_indexing_break', 5); + $this->cronRun(); + // Now all new and changed items should have been indexed, except item 5. + $results = $this->doSearch(); + $this->assertEqual($results['result count'], 6, 'Indexing order test 3: correct result count.'); + $this->assertEqual(array_keys($results['results']), array(2, 8, 11, 12, 13, 14), 'Indexing order test 3: correct results.'); + + // Index the remaining item. + variable_set('search_api_test_indexing_break', 0); + $this->cronRun(); + // Now all new and changed items should have been indexed. + $results = $this->doSearch(); + $this->assertEqual($results['result count'], 7, 'Indexing order test 4: correct result count.'); + $this->assertEqual(array_keys($results['results']), array(2, 5, 8, 11, 12, 13, 14), 'Indexing order test 4: correct results.'); } + /** + * Tests whether editing the server works correctly. + */ protected function editServer() { $values = array( 'name' => 'test-name-foo', @@ -357,12 +602,20 @@ class SearchApiWebTest extends DrupalWebTestCase { $this->assertText('test-test-baz', 'Service options changed.'); } + /** + * Tests whether clearing the index works correctly. + */ protected function clearIndex() { $this->drupalPost("admin/config/search/search_api/index/{$this->index_id}/status", array(), t('Clear index')); $this->assertText(t('The index was successfully cleared.')); - $this->assertText(t('All items still need to be indexed (@total total).', array('@total' => 10)), 'Correct index status displayed.'); + $this->assertText(t('All items still need to be indexed (@total total).', array('@total' => 14)), 'Correct index status displayed.'); } + /** + * Tests whether deleting the server works correctly. + * + * The index still lying on the server should be disabled and removed from it. + */ protected function deleteServer() { $this->drupalPost("admin/config/search/search_api/server/{$this->server_id}/delete", array(), t('Confirm')); $this->assertNoText('test-name-foo', 'Server no longer listed.'); @@ -370,6 +623,13 @@ class SearchApiWebTest extends DrupalWebTestCase { $this->assertText(t('The index is currently disabled.'), 'The index was disabled and removed from the server.'); } + /** + * Tests whether disabling and uninstalling the modules works correctly. + * + * This will disable and uninstall both the test module and the Search API. It + * asserts that this works correctly (since the server has been deleted in + * deleteServer()) and that all associated tables and variables are removed. + */ protected function disableModules() { module_disable(array('search_api_test'), FALSE); $this->assertFalse(module_exists('search_api_test'), 'Test module was successfully disabled.'); @@ -398,8 +658,19 @@ class SearchApiWebTest extends DrupalWebTestCase { */ class SearchApiUnitTest extends DrupalWebTestCase { + /** + * The index used by these tests. + * + * @var SearchApIindex + */ protected $index; + /** + * Overrides DrupalTestCase::assertEqual(). + * + * For arrays, checks whether all array keys are mapped the same in both + * arrays recursively, while ignoring their order. + */ protected function assertEqual($first, $second, $message = '', $group = 'Other') { if (is_array($first) && is_array($second)) { return $this->assertTrue($this->deepEquals($first, $second), $message, $group); @@ -409,6 +680,20 @@ class SearchApiUnitTest extends DrupalWebTestCase { } } + /** + * Tests whether two values are equal. + * + * For arrays, this is done by comparing the key/value pairs recursively + * instead of checking for simple equality. + * + * @param mixed $first + * The first value. + * @param mixed $second + * The second value. + * + * @return bool + * TRUE if the two values are equal, FALSE otherwise. + */ protected function deepEquals($first, $second) { if (!is_array($first) || !is_array($second)) { return $first == $second; @@ -424,6 +709,12 @@ class SearchApiUnitTest extends DrupalWebTestCase { return empty($second); } + /** + * Returns information about this test case. + * + * @return array + * An array with information about this test case. + */ public static function getInfo() { return array( 'name' => 'Test search API components', @@ -432,6 +723,9 @@ class SearchApiUnitTest extends DrupalWebTestCase { ); } + /** + * {@inheritdoc} + */ public function setUp() { parent::setUp('entity', 'search_api'); $this->index = entity_create('search_api_index', array( @@ -455,6 +749,12 @@ class SearchApiUnitTest extends DrupalWebTestCase { )); } + /** + * Tests the functionality of several components of the module. + * + * This is the single test method called by the Simpletest framework. It in + * turn calls other helper methods to test specific functionality. + */ public function testUnits() { $this->checkQueryParseKeys(); $this->checkIgnoreCaseProcessor(); @@ -462,11 +762,13 @@ class SearchApiUnitTest extends DrupalWebTestCase { $this->checkHtmlFilter(); } - public function checkQueryParseKeys() { + /** + * Checks whether the keys are parsed correctly by the query class. + */ + protected function checkQueryParseKeys() { $options['parse mode'] = 'direct'; $mode = &$options['parse mode']; $query = new SearchApiQuery($this->index, $options); - $modes = $query->parseModes(); $query->keys('foo'); $this->assertEqual($query->getKeys(), 'foo', '"Direct query" parse mode, test 1.'); @@ -499,8 +801,10 @@ class SearchApiUnitTest extends DrupalWebTestCase { $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'Münster'), '"Multiple terms" parse mode, test 4.'); } - public function checkIgnoreCaseProcessor() { - $types = search_api_field_types(); + /** + * Tests the functionality of the "Ignore case" processor. + */ + protected function checkIgnoreCaseProcessor() { $orig = 'Foo bar BaZ, ÄÖÜÀÁ<>»«.'; $processed = drupal_strtolower($orig); $items = array( @@ -566,7 +870,10 @@ class SearchApiUnitTest extends DrupalWebTestCase { $this->assertEqual($query->getFilter()->getFilters(), $filters2, 'Filters were processed correctly.'); } - public function checkTokenizer() { + /** + * Tests the functionality of the "Tokenizer" processor. + */ + protected function checkTokenizer() { $orig = 'Foo bar1 BaZ, La-la-la.'; $processed1 = array( array( @@ -648,7 +955,10 @@ class SearchApiUnitTest extends DrupalWebTestCase { $this->assertEqual($query->getKeys(), 'foo"b r b z"foob r1', 'Search keys were processed correctly.'); } - public function checkHtmlFilter() { + /** + * Tests the functionality of the "HTML filter" processor. + */ + protected function checkHtmlFilter() { $orig = <<a test. diff --git a/tests/search_api_test.module b/tests/search_api_test.module index 8a4ff82..0fd527f 100644 --- a/tests/search_api_test.module +++ b/tests/search_api_test.module @@ -11,15 +11,15 @@ function search_api_test_menu() { 'page arguments' => array('search_api_test_insert_item'), 'access callback' => TRUE, ), - 'search_api_test/%search_api_test' => array( + 'search_api_test/view/%search_api_test' => array( 'title' => 'View item', 'page callback' => 'search_api_test_view', - 'page arguments' => array(1), + 'page arguments' => array(2), 'access callback' => TRUE, ), - 'search_api_test/query/%search_api_index' => array( - 'title' => 'Search query', - 'page callback' => 'search_api_test_query', + 'search_api_test/touch/%search_api_test' => array( + 'title' => 'Mark item as changed', + 'page callback' => 'search_api_test_touch', 'page arguments' => array(2), 'access callback' => TRUE, ), @@ -81,38 +81,10 @@ function search_api_test_view($entity) { } /** - * Menu callback for executing a search. + * Menu callback for marking a "search_api_test" entity as changed. */ -function search_api_test_query(SearchApiIndex $index, $keys = 'foo bar', $offset = 0, $limit = 10, $fields = NULL, $sort = NULL, $filters = NULL) { - $query = $index->query() - ->keys($keys ? $keys : NULL) - ->range($offset, $limit); - if ($fields) { - $query->fields(explode(',', $fields)); - } - if ($sort) { - $sort = explode(',', $sort); - $query->sort($sort[0], $sort[1]); - } - else { - $query->sort('search_api_id', 'ASC'); - } - if ($filters) { - $filters = explode(',', $filters); - foreach ($filters as $filter) { - $filter = explode('=', $filter); - $query->condition($filter[0], $filter[1]); - } - } - $result = $query->execute(); - - $ret = ''; - $ret .= 'result count = ' . (int) $result['result count'] . '
'; - $ret .= 'results = (' . (empty($result['results']) ? '' : implode(', ', array_keys($result['results']))) . ')
'; - $ret .= 'warnings = (' . (empty($result['warnings']) ? '' : '"' . implode('", "', $result['warnings']) . '"') . ')
'; - $ret .= 'ignored = (' . (empty($result['ignored']) ? '' : implode(', ', $result['ignored'])) . ')
'; - $ret .= nl2br(check_plain(print_r($result['performance'], TRUE))); - return $ret; +function search_api_test_touch($entity) { + module_invoke_all('entity_update', $entity, 'search_api_test'); } /** @@ -234,6 +206,11 @@ function search_api_test_search_api_service_info() { */ class SearchApiTestService extends SearchApiAbstractService { + /** + * Overrides SearchApiAbstractService::configurationForm(). + * + * Returns a single text field for testing purposes. + */ public function configurationForm(array $form, array &$form_state) { $form = array( 'test' => array( @@ -249,37 +226,46 @@ class SearchApiTestService extends SearchApiAbstractService { return $form; } + /** + * Implements SearchApiServiceInterface::indexItems(). + * + * Indexes items by storing their IDs in the server's options. + * + * If the "search_api_test_indexing_break" variable is set, the item with + * that ID will not be indexed. + */ public function indexItems(SearchApiIndex $index, array $items) { - // Refuse to index items with IDs that are multiples of 8 unless the - // "search_api_test_index_all" variable is set. - if (variable_get('search_api_test_index_all', FALSE)) { - return $this->index($index, array_keys($items)); - } - $ret = array(); + // Refuse to index the item with the same ID as the + // "search_api_test_indexing_break" variable, if it is set. + $exclude = variable_get('search_api_test_indexing_break', 8); foreach ($items as $id => $item) { - if ($id % 8) { - $ret[] = $id; + if ($id == $exclude) { + unset($items[$id]); } } - return $this->index($index, $ret); - } + $ids = array_keys($items); - protected function index(SearchApiIndex $index, array $ids) { $this->options += array('indexes' => array()); $this->options['indexes'] += array($index->machine_name => array()); $this->options['indexes'][$index->machine_name] += drupal_map_assoc($ids); - sort($this->options['indexes'][$index->machine_name]); + asort($this->options['indexes'][$index->machine_name]); $this->server->save(); + return $ids; } /** - * Override so deleteItems() isn't called which would otherwise lead to the + * Overrides SearchApiAbstractService::preDelete(). + * + * Overridden so deleteItems() isn't called which would otherwise lead to the * server being updated and, eventually, to a notice because there is no * server to be updated anymore. */ public function preDelete() {} + /** + * {@inheritdoc} + */ public function deleteItems($ids = 'all', SearchApiIndex $index = NULL) { if ($ids == 'all') { if ($index) { @@ -297,6 +283,12 @@ class SearchApiTestService extends SearchApiAbstractService { $this->server->save(); } + /** + * Implements SearchApiServiceInterface::indexItems(). + * + * Will ignore all query settings except the range, as only the item IDs are + * indexed. + */ public function search(SearchApiQueryInterface $query) { $options = $query->getOptions(); $ret = array(); @@ -328,6 +320,9 @@ class SearchApiTestService extends SearchApiAbstractService { return $ret; } + /** + * {@inheritdoc} + */ public function fieldsUpdated(SearchApiIndex $index) { return db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField() > 0; }