浏览代码

upadted to 1.8

Bachir Soussi Chiadmi 11 年之前
父节点
当前提交
128640cd15
共有 52 个文件被更改,包括 2595 次插入1006 次删除
  1. 96 0
      CHANGELOG.txt
  2. 20 19
      README.txt
  3. 24 12
      contrib/search_api_facetapi/plugins/facetapi/adapter.inc
  4. 24 5
      contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc
  5. 2 2
      contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc
  6. 3 3
      contrib/search_api_facetapi/search_api_facetapi.info
  7. 42 9
      contrib/search_api_facetapi/search_api_facetapi.module
  8. 45 2
      contrib/search_api_views/README.txt
  9. 0 1
      contrib/search_api_views/includes/display_facet_block.inc
  10. 31 17
      contrib/search_api_views/includes/handler_argument.inc
  11. 18 1
      contrib/search_api_views/includes/handler_argument_fulltext.inc
  12. 5 2
      contrib/search_api_views/includes/handler_argument_more_like_this.inc
  13. 26 0
      contrib/search_api_views/includes/handler_argument_string.inc
  14. 104 0
      contrib/search_api_views/includes/handler_argument_taxonomy_term.inc
  15. 0 17
      contrib/search_api_views/includes/handler_argument_text.inc
  16. 29 2
      contrib/search_api_views/includes/handler_filter_fulltext.inc
  17. 66 29
      contrib/search_api_views/includes/handler_filter_options.inc
  18. 128 0
      contrib/search_api_views/includes/plugin_cache.inc
  19. 94 15
      contrib/search_api_views/includes/query.inc
  20. 7 5
      contrib/search_api_views/search_api_views.info
  21. 10 0
      contrib/search_api_views/search_api_views.module
  22. 58 6
      contrib/search_api_views/search_api_views.views.inc
  23. 27 62
      includes/callback.inc
  24. 11 5
      includes/callback_add_hierarchy.inc
  25. 7 5
      includes/callback_add_viewed_entity.inc
  26. 3 3
      includes/callback_bundle_filter.inc
  27. 1 1
      includes/callback_node_access.inc
  28. 1 1
      includes/callback_node_status.inc
  29. 65 0
      includes/callback_role_filter.inc
  30. 83 6
      includes/datasource.inc
  31. 10 10
      includes/datasource_entity.inc
  32. 3 12
      includes/datasource_external.inc
  33. 184 126
      includes/index_entity.inc
  34. 46 4
      includes/processor.inc
  35. 401 0
      includes/processor_highlight.inc
  36. 4 1
      includes/processor_ignore_case.inc
  37. 17 3
      includes/processor_stopwords.inc
  38. 21 7
      includes/processor_tokenizer.inc
  39. 15 0
      includes/processor_transliteration.inc
  40. 177 314
      includes/query.inc
  41. 38 102
      includes/service.inc
  42. 20 6
      search_api.admin.inc
  43. 33 9
      search_api.api.php
  44. 4 0
      search_api.drush.inc
  45. 6 3
      search_api.info
  46. 9 2
      search_api.install
  47. 433 60
      search_api.module
  48. 1 2
      search_api.rules.inc
  49. 107 91
      search_api.test
  50. 3 3
      tests/search_api_test.info
  51. 8 2
      tests/search_api_test.install
  52. 25 19
      tests/search_api_test.module

+ 96 - 0
CHANGELOG.txt

@@ -1,6 +1,102 @@
 Search API 1.x, dev (xx/xx/xxxx):
 ---------------------------------
 
+Search API 1.8 (09/01/2013):
+----------------------------
+- #1414048 by drunken monkey: Fixed exception in views.inc removes all Search
+  API tables.
+- #1921690 by drunken monkey: Fixed stale Views cache when indexed fields
+  change.
+- #2077035 by maciej.zgadzaj: Fixed whitespace recognition for search keys.
+- #2071229 by drunken monkey: Fixed use of core search constant.
+- #2069023 by drunken monkey: Fixed reaction to disabled modules.
+- #2057867 by drunken monkey: Fixed multiple values for taxonomy contextual
+  filter.
+- #2052701 by drunken monkey, erdos: Fixed cron queue state when disabling the
+  module.
+- #1878606 by drunken monkey: Fixed labels for boolean facets.
+- #2053171 by drunken monkey: Improved tests.
+- #1433720 by davidwbarratt, drunken monkey, JvE: Fixed handling of empty
+  selection for checkboxes.
+- #1414078 by drunken monkey, jaxxed: Fixed revert of exportables.
+- #2011396 by drunken monkey: Fixed support for several facets on a single
+  field.
+- #2050117 by izus, drunken monkey: Updated README.txt to reflect removed
+  sub-modules.
+- #2041365 by drunken monkey: Fixed error reporting for the MLT contextual
+  filter.
+- #2044711 by stBorchert, drunken monkey: Fixed facet adapter's
+  getCurrentSearch() method to not cache failed attempts.
+- #1411712 by Krasnyj, drunken monkey: Fixed notices in Views with groups.
+- #1959506 by jantoine, drunken monkey: Fixed "search id" for Views facets
+  block display.
+- #1902168 by rbruhn, drunken monkey, mpv: Fixed fatal error during Features
+  import.
+- #2040111 by arpieb: Fixed Views URL argument handler to allow multiple values.
+- #1064520 by drunken monkey: Added a processor for highlighting.
+
+Search API 1.7 (07/01/2013):
+----------------------------
+- #1612708 by drunken monkey: Fixed Views caching with facet blocks.
+- #2024189 by drunken monkey: Improved serialization of the query class.
+- #1311260 by drunken monkey: Fixed tokenizing of string fields.
+- #1246998 by drunken monkey: Fixed deletion of items in read-only indexes.
+- #1310970 by drunken monkey: Added improved UI help for determining which
+  fields are available for sorting.
+- #1886738 by chx, Jelle_S, drunken monkey: Added Role filter data alteration.
+- #1837782 by drunken monkey: Fixed enabling of indexes through the Status tab.
+- #1382170 by orakili, lliss, drunken monkey: Added OR filtering for Views
+  option filter.
+- #2012706 by drunken monkey: Fixed $reset parameter for load functions.
+- #1851204 by mvc: Fixed exception when indexing book hierarchy.
+- #1926030 by stella: Added field machine name to indexes' "Fields" tabs.
+- #1879102 by fearlsgroove: Fixed Drush attempting to index 0 items.
+- #1999858 by drunken monkey: Cleaned up API documentation for data alterations.
+- #2010116 by drunken monkey: Enabled "Index items immediately" for the default
+  node index.
+- #2013581 by drunken monkey: Added multi-valued field to test module.
+- #1288724 by brunodbo, drunken monkey, fearlsgroove: Added option for using OR
+  in Views fulltext search.
+- #1694832 by drunken monkey: Fixed index field settings getting stale when
+  Field API field settings change.
+- #1285794 by drunken monkey: Fixed "All" option in Views' exposed "Items per
+  page" setting.
+
+Search API 1.6 (05/29/2013):
+----------------------------
+- #1649976 by Berdir, ilari.stenroth, drunken monkey: Fixed memory error during
+  crons run for large indexes.
+- #1346276 by drunken monkey: Fixed Tokenizer should only run on fulltext
+  fields.
+- #1697246 by drunken monkey: Added 'Parse mode' option to views.
+- #1993536 by drunken monkey, jpieck: Fixed handling of empty values in
+  processors.
+- #1992228 by drunken monkey: Fixed current search block for empty keys.
+- #1696434 by orakili, ldweeks, drunken monkey: Added Views argument handler for
+  all indexed taxonomy term fields.
+- #1988238 by esbenvb, drunken monkey: Fixed Views result display for deleted
+  entities.
+- #872912 by drunken monkey: Expanded and fixed test cases.
+- #1760706 by jgraham, das-peter, drunken monkey: Added a flexible way for
+  determining whether an index contains entities.
+
+Search API 1.5 (05/04/2013):
+----------------------------
+- #1169254 by cslavoie, drunken monkey, DYdave: Added transliteration processor.
+- #1959088 by drunken monkey: Fixed titles for contextual filters.
+- #1792296 by andrewbelcher, drunken monkey: Added a group for Search API hooks.
+- #1407844 by nbucknor: Added "exclude" option for Views contextual filters.
+- #1278942 by Simon Georges, drunken monkey: Added an option to apply
+  entity_access() to Views results.
+- #1819412 by drunken monkey: Added clean way for retrieving an index's data
+  alterations and processors.
+- #1838134 by das-peter, drunken monkey: Added hook_search_api_items_indexed().
+- #1471310 by drunken monkey: Fixed handling of unset fields when indexing.
+- #1944394 by drunken monkey: Added caching to SearchApiIndex::getFields().
+- #1594762 by drunken monkey, alanom, esclapes: Fixed detection of deleted items
+  in the Hierarchy data alteration.
+- #1702604 by JvE, slucero: Added option for maximum date facet depth.
+
 Search API 1.4 (01/09/2013):
 ----------------------------
 - #1827272 by drunken monkey: Fixed regression introduced by #1777710.

+ 20 - 19
README.txt

@@ -105,8 +105,10 @@ IMPORTANT: Access checks
   specific search types, if available.
 
 As stated above, you will need at least one other module to use the Search API,
-namely one that defines a service class (e.g. search_api_db ("Database search"),
-provided with this module).
+namely one that defines a service class (e.g., search_api_db ("Database search")
+which can be found at [3]).
+
+[3] http://drupal.org/project/search_api_db
 
 - Creating a server
   (Configuration > Search API > Add server)
@@ -208,6 +210,12 @@ 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
 --------------------------
@@ -221,9 +229,9 @@ Information for developers
  | For custom field types to be available for indexing, provide a
  | "property_type" key in hook_field_info(), and optionally a callback at the
  | "property_callbacks" key.
- | Both processes are explained in [1].
+ | Both processes are explained in [4].
  |
- | [1] http://drupal.org/node/1021466
+ | [4] http://drupal.org/node/1021466
 
 Apart from improving the module itself, developers can extend search
 capabilities provided by the Search API by providing implementations for one (or
@@ -231,7 +239,9 @@ several) of the following classes. Detailed documentation on the methods that
 need to be implemented are always available as doc comments in the respective
 interface definition (all found in their respective files in the includes/
 directory). The details for hooks can be looked up in the search_api.api.php
-file.
+file. Note that all hooks provided by the Search API use the "search_api" hook
+group. Therefore, implementations of the hook can be moved into a
+MODULE.search_api.inc file in your module's directory.
 For all interfaces there are handy base classes which can (but don't need to) be
 used to ease custom implementations, since they provide sensible generic
 implementations for many methods. They, too, should be documented well enough
@@ -255,7 +265,9 @@ service class.
 The central methods here are the indexItems() and the search() methods, which
 always have to be overridden manually. The configurationForm() method allows
 services to provide custom settings for the user.
-See the SearchApiDbService class for an example implementation.
+See the SearchApiDbService class provided by [5] for an example implementation.
+
+[5] http://drupal.org/project/search_api_db
 
 - Query class
   Interface: SearchApiQueryInterface
@@ -334,15 +346,6 @@ See the processors in includes/processor.inc for examples.
 Included components
 -------------------
 
-- Service classes
-
-  * Database search
-    A search server implementation that uses the normal database for indexing
-    data. It isn't very fast and the results might also be less accurate than
-    with third-party solutions like Solr, but it's very easy to set up and good
-    for smaller applications or testing.
-    See contrib/search_api_db/README.txt for details.
-
 - Data alterations
 
   * URL field
@@ -391,14 +394,12 @@ Included components
 
 - Additional modules
 
-  * Search pages
-    This module lets you create simple search pages for indexes.
   * Search views
-    This integrates the Search API with the Views module [1], enabling the user
+    This integrates the Search API with the Views module [6], enabling the user
     to create views which display search results from any Search API index.
   * Search facets
     For service classes supporting this feature (e.g. Solr search), this module
     automatically provides configurable facet blocks on pages that execute
     a search query.
 
-[1] http://drupal.org/project/views
+[6] http://drupal.org/project/views

+ 24 - 12
contrib/search_api_facetapi/plugins/facetapi/adapter.inc

@@ -128,8 +128,10 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
    *   search_api_current_search(). Or NULL, if no match was found.
    */
   public function getCurrentSearch() {
+    // Even if this fails once, there might be a search query later in the page
+    // request. We therefore don't store anything in $this->current_search in
+    // case of failure, but just try again if the method is called again.
     if (!isset($this->current_search)) {
-      $this->current_search = FALSE;
       $index_id = $this->info['instance'];
       // There is currently no way to configure the "current search" block to
       // show on a per-searcher basis as we do with the facets. Therefore we
@@ -143,7 +145,7 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
         }
       }
     }
-    return $this->current_search ? $this->current_search : NULL;
+    return $this->current_search;
   }
 
   /**
@@ -172,16 +174,6 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
       // properly.
       $keys = '[' . t('complex query') . ']';
     }
-    elseif (!$keys) {
-      // If a base path other than the current one is set, we assume that we
-      // shouldn't report on the current search. Highly hack-y, of course.
-      if ($search[0]->getOption('search_api_base_path', $_GET['q']) !== $_GET['q']) {
-        return NULL;
-      }
-      // Work-around since Facet API won't show the "Current search" block
-      // without keys.
-      $keys = '[' . t('all items') . ']';
-    }
     drupal_alter('search_api_facetapi_keys', $keys, $search[0]);
     return $keys;
   }
@@ -238,5 +230,25 @@ class SearchApiFacetapiAdapter extends FacetapiAdapter {
         '#value' => array(),
       );
     }
+
+    // Add a granularity option to date query types.
+    if (isset($facet['query type']) && $facet['query type'] == 'date') {
+      $granularity_options = array(
+        FACETAPI_DATE_YEAR => t('Years'),
+        FACETAPI_DATE_MONTH => t('Months'),
+        FACETAPI_DATE_DAY => t('Days'),
+        FACETAPI_DATE_HOUR => t('Hours'),
+        FACETAPI_DATE_MINUTE => t('Minutes'),
+        FACETAPI_DATE_SECOND => t('Seconds'),
+      );
+
+      $form['global']['date_granularity'] = array(
+        '#type' => 'select',
+        '#title' => t('Granularity'),
+        '#description' => t('Determine the maximum drill-down level'),
+        '#options' => $granularity_options,
+        '#default_value' => isset($options['date_granularity']) ? $options['date_granularity'] : FACETAPI_DATE_MINUTE,
+      );
+    }
   }
 }

+ 24 - 5
contrib/search_api_facetapi/plugins/facetapi/query_type_date.inc

@@ -85,8 +85,8 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
     // this method.
 
     // Executes query, iterates over results.
-    if (isset($results['search_api_facets']) && isset($results['search_api_facets'][$this->facet['field']])) {
-      $values = $results['search_api_facets'][$this->facet['field']];
+    if (isset($results['search_api_facets']) && isset($results['search_api_facets'][$this->facet['name']])) {
+      $values = $results['search_api_facets'][$this->facet['name']];
       foreach ($values as $value) {
         if ($value['count']) {
           $filter = $value['filter'];
@@ -115,20 +115,24 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
       }
     }
 
+    // Get the finest level of detail we're allowed to drill down to.
+    $settings = $facet->getSettings()->settings;
+    $granularity = isset($settings['date_granularity']) ? $settings['date_granularity'] : FACETAPI_DATE_MINUTE;
+
     // Gets active facets, starts building hierarchy.
     $parent = $gap = NULL;
     foreach ($this->adapter->getActiveItems($this->facet) as $value => $item) {
       // If the item is active, the count is the result set count.
       $build[$value] = array('#count' => $total);
 
-      // Gets next "gap" increment, minute being the lowest we can go.
+      // Gets next "gap" increment.
       if ($value[0] != '[' || $value[strlen($value) - 1] != ']' || !($pos = strpos($value, ' TO '))) {
         continue;
       }
       $start = substr($value, 1, $pos);
       $end = substr($value, $pos + 4, -1);
       $date_gap = facetapi_get_date_gap($start, $end);
-      $gap = facetapi_get_next_date_gap($date_gap, FACETAPI_DATE_MINUTE);
+      $gap = facetapi_get_next_date_gap($date_gap, $granularity);
 
       // If there is a previous item, there is a parent, uses a reference so the
       // arrays are populated when they are updated.
@@ -150,9 +154,24 @@ class SearchApiFacetapiDate extends SearchApiFacetapiTerm implements FacetapiQue
     if (NULL === $parent) {
       if (count($raw_values) > 1) {
         $gap = facetapi_get_timestamp_gap(min($timestamps), max($timestamps));
+        // Array of numbers used to determine whether the next gap is smaller than
+        // the minimum gap allowed in the drilldown.
+        $gap_numbers = array(
+          FACETAPI_DATE_YEAR => 6,
+          FACETAPI_DATE_MONTH => 5,
+          FACETAPI_DATE_DAY => 4,
+          FACETAPI_DATE_HOUR => 3,
+          FACETAPI_DATE_MINUTE => 2,
+          FACETAPI_DATE_SECOND => 1,
+        );
+        // Gets gap numbers for both the gap and minimum gap, checks if the gap
+        // is within the limit set by the $granularity parameter.
+        if ($gap_numbers[$gap] < $gap_numbers[$granularity]) {
+          $gap = $granularity;
+        }
       }
       else {
-        $gap = FACETAPI_DATE_HOUR;
+        $gap = $granularity;
       }
     }
 

+ 2 - 2
contrib/search_api_facetapi/plugins/facetapi/query_type_term.inc

@@ -120,8 +120,8 @@ class SearchApiFacetapiTerm extends FacetapiQueryType implements FacetapiQueryTy
     $search = search_api_current_search($search_id);
     $build = array();
     $results = $search[1];
-    if (isset($results['search_api_facets']) && isset($results['search_api_facets'][$this->facet['field']])) {
-      $values = $results['search_api_facets'][$this->facet['field']];
+    if (isset($results['search_api_facets']) && isset($results['search_api_facets'][$this->facet['name']])) {
+      $values = $results['search_api_facets'][$this->facet['name']];
       foreach ($values as $value) {
         $filter = $value['filter'];
         // As Facet API isn't really suited for our native facet filter

+ 3 - 3
contrib/search_api_facetapi/search_api_facetapi.info

@@ -9,9 +9,9 @@ files[] = plugins/facetapi/adapter.inc
 files[] = plugins/facetapi/query_type_term.inc
 files[] = plugins/facetapi/query_type_date.inc
 
-; Information added by drupal.org packaging script on 2013-01-09
-version = "7.x-1.4"
+; Information added by drupal.org packaging script on 2013-09-01
+version = "7.x-1.8"
 core = "7.x"
 project = "search_api"
-datestamp = "1357726719"
+datestamp = "1378025826"
 

+ 42 - 9
contrib/search_api_facetapi/search_api_facetapi.module

@@ -65,6 +65,9 @@ function search_api_facetapi_facetapi_searcher_info() {
         'supports facet mincount' => TRUE,
         'include default facets' => FALSE,
       );
+      if (($entity_type = $index->getEntityType()) && $entity_type !== $index->item_type) {
+        $info[$searcher_name]['types'][] = $entity_type;
+      }
     }
   }
   return $info;
@@ -80,7 +83,7 @@ function search_api_facetapi_facetapi_facet_info(array $searcher_info) {
     if (!empty($index->options['fields'])) {
       $wrapper = $index->entityWrapper();
       $bundle_key = NULL;
-      if (($entity_info = entity_get_info($index->item_type)) && !empty($entity_info['bundle keys']['bundle'])) {
+      if ($index->getEntityType() && ($entity_info = entity_get_info($index->getEntityType())) && !empty($entity_info['bundle keys']['bundle'])) {
         $bundle_key = $entity_info['bundle keys']['bundle'];
       }
 
@@ -144,7 +147,7 @@ function search_api_facetapi_facetapi_facet_info(array $searcher_info) {
         if ($bundle_key) {
           if ($key === $bundle_key) {
             // Set entity type this field contains bundle information for.
-            $facet_info[$key]['field api bundles'][] = $index->item_type;
+            $facet_info[$key]['field api bundles'][] = $index->getEntityType();
           }
           else {
             // Add "bundle" as possible dependency plugin.
@@ -313,25 +316,49 @@ function search_api_facetapi_facet_map_callback(array $values, array $options =
 
 /**
  * Creates a human-readable label for single facet filter values.
+ *
+ * @param array $values
+ *   The values for which labels should be returned.
+ * @param array $options
+ *   An associative array containing the following information about the facet:
+ *   - field: Field information, as stored in the index, but with an additional
+ *     "key" property set to the field's internal name.
+ *   - index id: The machine name of the index for this facet.
+ *   - map callback: (optional) A callback that will be called at the beginning,
+ *     which allows initial mapping of filters. Only values not mapped by that
+ *     callback will be processed by this method.
+ *   - value callback: A callback used to map single values and the limits of
+ *     ranges. The signature is the same as for this function, but all values
+ *     will be single values.
+ *   - missing label: (optional) The label used for the "missing" facet.
+ *
+ * @return array
+ *   An array mapping raw facet values to their labels.
  */
 function _search_api_facetapi_facet_create_label(array $values, array $options) {
   $field = $options['field'];
+  $map = array();
+  $n = count($values);
+
   // For entities, we can simply use the entity labels.
   if (isset($field['entity_type'])) {
     $type = $field['entity_type'];
     $entities = entity_load($type, $values);
-    $map = array();
     foreach ($entities as $id => $entity) {
       $label = entity_label($type, $entity);
       if ($label) {
         $map[$id] = $label;
       }
     }
-    return $map;
+    if (count($map) == $n) {
+      return $map;
+    }
   }
+
   // Then, we check whether there is an options list for the field.
   $index = search_api_index_load($options['index id']);
   $wrapper = $index->entityWrapper();
+  $values = drupal_map_assoc($values);
   foreach (explode(':', $field['key']) as $part) {
     if (!isset($wrapper->$part)) {
       $wrapper = NULL;
@@ -342,12 +369,18 @@ function _search_api_facetapi_facet_create_label(array $values, array $options)
       $wrapper = $wrapper[0];
     }
   }
-  if ($wrapper && ($options = $wrapper->optionsList('view'))) {
-    return $options;
+  if ($wrapper && ($options_list = $wrapper->optionsList('view'))) {
+    // We have no use for empty strings, as then the facet links would be
+    // invisible.
+    $map += array_intersect_key(array_filter($options_list, 'strlen'), $values);
+    if (count($map) == $n) {
+      return $map;
+    }
   }
-  // As a "last resort" we try to create a label based on the field type.
-  $map = array();
-  foreach ($values as $value) {
+
+  // As a "last resort" we try to create a label based on the field type, for
+  // all values that haven't got a mapping yet.
+  foreach (array_diff_key($values, $map) as $value) {
     switch ($field['type']) {
       case 'boolean':
         $map[$value] = $value ? t('true') : t('false');

+ 45 - 2
contrib/search_api_views/README.txt

@@ -54,8 +54,7 @@ linked to for the filter to have an effect.
 Since the block will trigger a search on pages where it is set to appear, you
 can also enable additional „normal“ facet blocks for that search, via the
 „Facets“ tab for the index. They will automatically also point to the same
-search that you specified for the display. The Search ID of the „Facets blocks“
-display can easily be recognized by the "-facet_block" suffix.
+search that you specified for the display.
 If you want to use only the normal facets and not display anything at all in
 the Views block, just activate the display's „Hide block“ option.
 
@@ -63,6 +62,50 @@ Note: If you want to display the block not only on a few pages, you should in
 any case take care that it isn't displayed on the search page, since that might
 confuse users.
 
+Access features
+---------------
+Search views created with this module contain two query settings (located in
+the "Advanced" fieldset) which let you control the access checks executed for
+search results displayed in the view.
+
+- Bypass access checks
+This option allows you to deactivate access filters that would otherwise be
+added to the search, if the index supports this. This is, for instance, the case
+for indexes on the "Node" item type, when the "Node access" data alteration is
+activated.
+Use this either to slightly speed up searches where additional checks are
+unnecessary (e.g., because you already filter on "Node: Published") and there is
+no other node access mechanism on your site) or to show certain data that users
+normally wouldn't have access to (e.g., a list of all matching node titles,
+published or not).
+
+- Additional access checks on result entities
+When this option is activated, all result entities will be passed to an
+additional access check, even if search-time access checks are available for
+this index. The advantage is that access rules are guaranteed to be enforced –
+stale data in the index, which might make other access checks incorrect, won't
+influence this access check. You can also use it for item types for which no
+other access mechanisms are available.
+However, note that results filtered out this way will mess up paging, result
+counts and possibly other things too (like facet counts), as the result row is
+only hidden from display after the search has been executed. Where possible,
+you should therefore only use this in combination with appropriate filter
+settings ensuring that only when the index isn't up-to-date items will be
+filtered out this way.
+This option is only available for indexes on entity types.
+
+Other features
+--------------
+- Change parse mode
+You can determine how search keys entered by the user will be parsed by going to
+"Advanced" > "Query settings" within your View's settings. "Direct" can be
+useful, e.g., when you want to give users the full power of Solr. In other
+cases, "Multiple terms" is usually what you want / what users expect.
+Caution: For letting users use fulltext searches, always use the "Search:
+Fulltext search" filter or contextual filter – using a normal filter on a
+fulltext field won't parse the search keys, which means multiple words will only
+be found when they appear as that exact phrase.
+
 FAQ: Why „*Indexed* Node“?
 --------------------------
 The group name used for the search result itself (in fields, filters, etc.) is

+ 0 - 1
contrib/search_api_views/includes/display_facet_block.inc

@@ -177,7 +177,6 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block {
         'min_count' => 1,
       );
     }
-    $query_options['search id'] = 'search_api_views:' . $this->view->name . '-facets_block';
     $query_options['search_api_base_path'] = $base_path;
     $this->view->query->range(0, 0);
 

+ 31 - 17
contrib/search_api_views/includes/handler_argument.inc

@@ -12,6 +12,17 @@ class SearchApiViewsHandlerArgument extends views_handler_argument {
    */
   public $query;
 
+  /**
+   * The operator to use for multiple arguments.
+   *
+   * Either "and" or "or".
+   *
+   * @var string
+   *
+   * @see views_break_phrase
+   */
+  public $operator;
+
   /**
    * Determine if the argument can generate a breadcrumb
    *
@@ -64,6 +75,7 @@ class SearchApiViewsHandlerArgument extends views_handler_argument {
     $options = parent::option_definition();
 
     $options['break_phrase'] = array('default' => FALSE);
+    $options['not'] = array('default' => FALSE);
 
     return $options;
   }
@@ -79,6 +91,14 @@ class SearchApiViewsHandlerArgument extends views_handler_argument {
       '#default_value' => $this->options['break_phrase'],
       '#fieldset' => 'more',
     );
+
+    $form['not'] = array(
+      '#type' => 'checkbox',
+      '#title' => t('Exclude'),
+      '#description' => t('If selected, the numbers entered for the filter will be excluded rather than limiting the view.'),
+      '#default_value' => !empty($this->options['not']),
+      '#fieldset' => 'more',
+    );
   }
 
   /**
@@ -86,37 +106,31 @@ class SearchApiViewsHandlerArgument extends views_handler_argument {
    *
    * The argument sent may be found at $this->argument.
    */
-  // @todo Provide options to select the operator, instead of always using '='?
   public function query($group_by = FALSE) {
-    if (!empty($this->options['break_phrase'])) {
-      views_break_phrase($this->argument, $this);
-    }
-    else {
-      $this->value = array($this->argument);
+    if (empty($this->value)) {
+      if (!empty($this->options['break_phrase'])) {
+        views_break_phrase($this->argument, $this);
+      }
+      else {
+        $this->value = array($this->argument);
+      }
     }
 
+    $operator = empty($this->options['not']) ? '=' : '<>';
+
     if (count($this->value) > 1) {
       $filter = $this->query->createFilter(drupal_strtoupper($this->operator));
       // $filter will be NULL if there were errors in the query.
       if ($filter) {
         foreach ($this->value as $value) {
-          $filter->condition($this->real_field, $value, '=');
+          $filter->condition($this->real_field, $value, $operator);
         }
         $this->query->filter($filter);
       }
     }
     else {
-      $this->query->condition($this->real_field, reset($this->value));
+      $this->query->condition($this->real_field, reset($this->value), $operator);
     }
   }
 
-  /**
-   * Get the title this argument will assign the view, given the argument.
-   *
-   * This usually needs to be overridden to provide a proper title.
-   */
-  public function title() {
-    return t('Search @field for "@arg"', array('@field' => $this->definition['title'], '@arg' => $this->argument));
-  }
-
 }

+ 18 - 1
contrib/search_api_views/includes/handler_argument_fulltext.inc

@@ -3,7 +3,7 @@
 /**
  * Views argument handler class for handling fulltext fields.
  */
-class SearchApiViewsHandlerArgumentFulltext extends SearchApiViewsHandlerArgumentText {
+class SearchApiViewsHandlerArgumentFulltext extends SearchApiViewsHandlerArgument {
 
   /**
    * Specify the options this filter uses.
@@ -11,6 +11,7 @@ class SearchApiViewsHandlerArgumentFulltext extends SearchApiViewsHandlerArgumen
   public function option_definition() {
     $options = parent::option_definition();
     $options['fields'] = array('default' => array());
+    $options['conjunction'] = array('default' => 'AND');
     return $options;
   }
 
@@ -20,6 +21,8 @@ class SearchApiViewsHandlerArgumentFulltext extends SearchApiViewsHandlerArgumen
   public function options_form(&$form, &$form_state) {
     parent::options_form($form, $form_state);
 
+    $form['help']['#markup'] = t('Note: You can change how search keys are parsed under "Advanced" > "Query settings".');
+
     $fields = $this->getFulltextFields();
     if (!empty($fields)) {
       $form['fields'] = array(
@@ -31,6 +34,17 @@ class SearchApiViewsHandlerArgumentFulltext extends SearchApiViewsHandlerArgumen
         '#multiple' => TRUE,
         '#default_value' => $this->options['fields'],
       );
+      $form['conjunction'] = array(
+        '#title' => t('Operator'),
+        '#description' => t('Determines how multiple keywords entered for the search will be combined.'),
+        '#type' => 'radios',
+        '#options' => array(
+          'AND' => t('Contains all of these words'),
+          'OR' => t('Contains any of these words'),
+        ),
+        '#default_value' => $this->options['conjunction'],
+      );
+
     }
     else {
       $form['fields'] = array(
@@ -49,6 +63,9 @@ class SearchApiViewsHandlerArgumentFulltext extends SearchApiViewsHandlerArgumen
     if ($this->options['fields']) {
       $this->query->fields($this->options['fields']);
     }
+    if ($this->options['conjunction'] != 'AND') {
+      $this->query->setOption('conjunction', $this->options['conjunction']);
+    }
 
     $old = $this->query->getOriginalKeys();
     $this->query->keys($this->argument);

+ 5 - 2
contrib/search_api_views/includes/handler_argument_more_like_this.inc

@@ -12,6 +12,7 @@ class SearchApiViewsHandlerArgumentMoreLikeThis extends SearchApiViewsHandlerArg
   public function option_definition() {
     $options = parent::option_definition();
     unset($options['break_phrase']);
+    unset($options['not']);
     $options['fields'] = array('default' => array());
     return $options;
   }
@@ -22,6 +23,7 @@ class SearchApiViewsHandlerArgumentMoreLikeThis extends SearchApiViewsHandlerArg
   public function options_form(&$form, &$form_state) {
     parent::options_form($form, $form_state);
     unset($form['break_phrase']);
+    unset($form['not']);
 
     $index = search_api_index_load(substr($this->table, 17));
     if (!empty($index->options['fields'])) {
@@ -58,8 +60,9 @@ class SearchApiViewsHandlerArgumentMoreLikeThis extends SearchApiViewsHandlerArg
     $server = $this->query->getIndex()->server();
     if (!$server->supportsFeature('search_api_mlt')) {
       $class = search_api_get_service_info($server->class);
-      throw new SearchApiException(t('The search service "@class" does not offer "More like this" functionality.',
-          array('@class' => $class['name'])));
+      watchdog('search_api_views', 'The search service "@class" does not offer "More like this" functionality.',
+          array('@class' => $class['name']), WATCHDOG_ERROR);
+      $this->query->abort();
       return;
     }
     $fields = $this->options['fields'] ? $this->options['fields'] : array();

+ 26 - 0
contrib/search_api_views/includes/handler_argument_string.inc

@@ -0,0 +1,26 @@
+<?php
+
+/**
+ * Views argument handler class for handling string fields.
+ */
+class SearchApiViewsHandlerArgumentString extends SearchApiViewsHandlerArgument {
+
+  /**
+   * Set up the query for this argument.
+   *
+   * The argument sent may be found at $this->argument.
+   */
+  public function query($group_by = FALSE) {
+    if (empty($this->value)) {
+      if (!empty($this->options['break_phrase'])) {
+        views_break_phrase_string($this->argument, $this);
+      }
+      else {
+        $this->value = array($this->argument);
+      }
+    }
+
+    parent::query($group_by);
+  }
+
+}

+ 104 - 0
contrib/search_api_views/includes/handler_argument_taxonomy_term.inc

@@ -0,0 +1,104 @@
+<?php
+
+/**
+ * @file
+ *   Contains the SearchApiViewsHandlerArgumentTaxonomyTerm class.
+ */
+
+/**
+ * Defines a contextual filter searching through all indexed taxonomy fields.
+ */
+class SearchApiViewsHandlerArgumentTaxonomyTerm extends SearchApiViewsHandlerArgument {
+
+  /**
+   * Set up the query for this argument.
+   *
+   * The argument sent may be found at $this->argument.
+   */
+  public function query($group_by = FALSE) {
+    if (empty($this->value)) {
+      $this->fillValue();
+    }
+
+    $outer_conjunction = strtoupper($this->operator);
+
+    if (empty($this->options['not'])) {
+      $operator = '=';
+      $inner_conjunction = 'OR';
+    }
+    else {
+      $operator = '<>';
+      $inner_conjunction = 'AND';
+    }
+
+    if (!empty($this->value)) {
+      $terms = entity_load('taxonomy_term', $this->value);
+
+      if (!empty($terms)) {
+        $filter = $this->query->createFilter($outer_conjunction);
+        $vocabulary_fields = $this->definition['vocabulary_fields'];
+        $vocabulary_fields += array('' => array());
+        foreach ($terms as $term) {
+          $inner_filter = $filter;
+          if ($outer_conjunction != $inner_conjunction) {
+            $inner_filter = $this->query->createFilter($inner_conjunction);
+          }
+          // Set filters for all term reference fields which don't specify a
+          // vocabulary, as well as for all fields specifying the term's
+          // vocabulary.
+          if (!empty($this->definition['vocabulary_fields'][$term->vocabulary_machine_name])) {
+            foreach ($this->definition['vocabulary_fields'][$term->vocabulary_machine_name] as $field) {
+              $inner_filter->condition($field, $term->tid, $operator);
+            }
+          }
+          foreach ($vocabulary_fields[''] as $field) {
+            $inner_filter->condition($field, $term->tid, $operator);
+          }
+          if ($outer_conjunction != $inner_conjunction) {
+            $filter->filter($inner_filter);
+          }
+        }
+
+        $this->query->filter($filter);
+      }
+    }
+  }
+
+  /**
+   * Get the title this argument will assign the view, given the argument.
+   */
+  public function title() {
+    if (!empty($this->argument)) {
+      if (empty($this->value)) {
+        $this->fillValue();
+      }
+      $terms = array();
+      foreach ($this->value as $tid) {
+        $taxonomy_term = taxonomy_term_load($tid);
+        if ($taxonomy_term) {
+          $terms[] = check_plain($taxonomy_term->name);
+        }
+      }
+
+      return $terms ? implode(', ', $terms) : check_plain($this->argument);
+    }
+    else {
+      return check_plain($this->argument);
+    }
+  }
+
+  /**
+   * Fill $this->value with data from the argument.
+   *
+   * Uses views_break_phrase(), if appropriate.
+   */
+  protected function fillValue() {
+    if (!empty($this->options['break_phrase'])) {
+      views_break_phrase($this->argument, $this);
+    }
+    else {
+      $this->value = array($this->argument);
+    }
+  }
+
+}

+ 0 - 17
contrib/search_api_views/includes/handler_argument_text.inc

@@ -1,17 +0,0 @@
-<?php
-
-/**
- * Views argument handler class for handling fulltext fields.
- */
-class SearchApiViewsHandlerArgumentText extends SearchApiViewsHandlerArgument {
-
-  /**
-   * Get the title this argument will assign the view, given the argument.
-   *
-   * This usually needs to be overridden to provide a proper title.
-   */
-  public function title() {
-    return t('Search for "@arg"', array('@field' => $this->definition['title'], '@arg' => $this->argument));
-  }
-
-}

+ 29 - 2
contrib/search_api_views/includes/handler_filter_fulltext.inc

@@ -5,12 +5,33 @@
  */
 class SearchApiViewsHandlerFilterFulltext extends SearchApiViewsHandlerFilterText {
 
+  /**
+   * Displays the operator form, adding a description.
+   */
+  public function show_operator_form(&$form, &$form_state) {
+    $this->operator_form($form, $form_state);
+    $form['operator']['#description'] = t('This operator is only useful when using \'Search keys\'.');
+  }
+
+  /**
+   * Provide a list of options for the operator form.
+   */
+  public function operator_options() {
+    return array(
+      'AND' => t('Contains all of these words'),
+      'OR' => t('Contains any of these words'),
+      'NOT' => t('Contains none of these words'),
+    );
+  }
+
   /**
    * Specify the options this filter uses.
    */
   public function option_definition() {
     $options = parent::option_definition();
 
+    $options['operator']['default'] = 'AND';
+
     $options['mode'] = array('default' => 'keys');
     $options['fields'] = array('default' => array());
 
@@ -27,7 +48,7 @@ class SearchApiViewsHandlerFilterFulltext extends SearchApiViewsHandlerFilterTex
       '#title' => t('Use as'),
       '#type' => 'radios',
       '#options' => array(
-        'keys' => t('Search keys – multiple words will be split and the filter will influence relevance.'),
+        'keys' => t('Search keys – multiple words will be split and the filter will influence relevance. You can change how search keys are parsed under "Advanced" > "Query settings".'),
         'filter' => t("Search filter – use as a single phrase that restricts the result set but doesn't influence relevance."),
       ),
       '#default_value' => $this->options['mode'],
@@ -87,10 +108,16 @@ class SearchApiViewsHandlerFilterFulltext extends SearchApiViewsHandlerFilterTex
       return;
     }
 
+    // If the operator was set to OR, set it as the conjunction. (AND is set by
+    // default.)
+    if ($this->operator === 'OR') {
+      $this->query->setOption('conjunction', $this->operator);
+    }
+
     $this->query->fields($fields);
     $old = $this->query->getOriginalKeys();
     $this->query->keys($this->value);
-    if ($this->operator != '=') {
+    if ($this->operator == 'NOT') {
       $keys = &$this->query->getKeys();
       if (is_array($keys)) {
         $keys['#negation'] = TRUE;

+ 66 - 29
contrib/search_api_views/includes/handler_filter_options.inc

@@ -15,12 +15,18 @@ class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
    * Provide a list of options for the operator form.
    */
   public function operator_options() {
-    return array(
+    $options = array(
       '=' => t('Is one of'),
-      '<>' => t('Is not one of'),
+      'all of' => t('Is all of'),
+      '<>' => t('Is none of'),
       'empty' => t('Is empty'),
       'not empty' => t('Is not empty'),
     );
+    // "Is all of" doesn't make sense for single-valued fields.
+    if (empty($this->definition['multi-valued'])) {
+      unset($options['all of']);
+    }
+    return $options;
   }
 
   /**
@@ -99,12 +105,10 @@ class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
       '#options' => $options,
       '#multiple' => TRUE,
       '#size' => min(4, count($this->definition['options'])),
-      '#default_value' => isset($this->value) ? $this->value : array(),
+      '#default_value' => is_array($this->value) ? $this->value : array(),
     );
-
     // Hide the value box if operator is 'empty' or 'not empty'.
     // Radios share the same selector so we have to add some dummy selector.
-    // #states replace #dependency (http://drupal.org/node/1595022).
     $form['value']['#states']['visible'] = array(
       ':input[name="options[operator]"],dummy-empty' => array('!value' => 'empty'),
       ':input[name="options[operator]"],dummy-not-empty' => array('!value' => 'not empty'),
@@ -142,11 +146,21 @@ class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
     }
     // Choose different kind of ouput for 0, a single and multiple values.
     if (count($this->value) == 0) {
-      return $this->operator == '=' ? t('none') : t('any');
+      return $this->operator != '<>' ? t('none') : t('any');
     }
     elseif (count($this->value) == 1) {
+      switch ($this->operator) {
+        case '=':
+        case 'all of':
+          $operator = '=';
+          break;
+
+        case '<>':
+          $operator = '<>';
+          break;
+      }
       // If there is only a single value, use just the plain operator, = or <>.
-      $operator = check_plain($this->operator);
+      $operator = check_plain($operator);
       $values = check_plain($this->definition['options'][reset($this->value)]);
     }
     else {
@@ -171,33 +185,56 @@ class SearchApiViewsHandlerFilterOptions extends SearchApiViewsHandlerFilter {
   public function query() {
     if ($this->operator === 'empty') {
       $this->query->condition($this->real_field, NULL, '=', $this->options['group']);
+      return;
     }
-    elseif ($this->operator === 'not empty') {
+    if ($this->operator === 'not empty') {
       $this->query->condition($this->real_field, NULL, '<>', $this->options['group']);
+      return;
     }
-    else {
-      while (is_array($this->value) && count($this->value) == 1) {
-        $this->value = reset($this->value);
-      }
-      if (is_scalar($this->value) && $this->value !== '') {
-        $this->query->condition($this->real_field, $this->value, $this->operator, $this->options['group']);
+
+    // Extract the value.
+    while (is_array($this->value) && count($this->value) == 1) {
+      $this->value = reset($this->value);
+    }
+
+    // Determine operator and conjunction.
+    switch ($this->operator) {
+      case '=':
+        $operator = '=';
+        $conjunction = 'OR';
+        break;
+
+      case 'all of':
+        $operator = '=';
+        $conjunction = 'AND';
+        break;
+
+      case '<>':
+        $operator = '<>';
+        $conjunction = 'AND';
+        break;
+    }
+
+    // If the value is an empty array, we either want no filter at all (for
+    // "is none of", or want to find only items with no value for the field.
+    if ($this->value === array()) {
+      if ($this->operator != '<>') {
+        $this->query->condition($this->real_field, NULL, '=', $this->options['group']);
       }
-      elseif ($this->value) {
-        if ($this->operator == '=') {
-          $filter = $this->query->createFilter('OR');
-          // $filter will be NULL if there were errors in the query.
-          if ($filter) {
-            foreach ($this->value as $v) {
-              $filter->condition($this->real_field, $v, '=');
-            }
-            $this->query->filter($filter, $this->options['group']);
-          }
-        }
-        else {
-          foreach ($this->value as $v) {
-            $this->query->condition($this->real_field, $v, $this->operator, $this->options['group']);
-          }
+      return;
+    }
+
+    if (is_scalar($this->value) && $this->value !== '') {
+      $this->query->condition($this->real_field, $this->value, $operator, $this->options['group']);
+    }
+    elseif ($this->value) {
+      $filter = $this->query->createFilter($conjunction);
+      // $filter will be NULL if there were errors in the query.
+      if ($filter) {
+        foreach ($this->value as $v) {
+          $filter->condition($this->real_field, $v, $operator);
         }
+        $this->query->filter($filter, $this->options['group']);
       }
     }
   }

+ 128 - 0
contrib/search_api_views/includes/plugin_cache.inc

@@ -0,0 +1,128 @@
+<?php
+
+/**
+ * @file
+ * Contains the SearchApiViewsCache class.
+ */
+
+/**
+ * Plugin class for caching Search API views.
+ */
+class SearchApiViewsCache extends views_plugin_cache_time {
+
+  /**
+   * Static cache for get_results_key().
+   *
+   * @var string
+   */
+  protected $_results_key = NULL;
+
+  /**
+   * Static cache for getSearchApiQuery().
+   *
+   * @var SearchApiQueryInterface
+   */
+  protected $search_api_query = NULL;
+
+  /**
+   * Overrides views_plugin_cache::cache_set().
+   *
+   * Also stores Search API's internal search results.
+   */
+  public function cache_set($type) {
+    if ($type != 'results') {
+      return parent::cache_set($type);
+    }
+
+    $cid = $this->get_results_key();
+    $data = array(
+      'result' => $this->view->result,
+      'total_rows' => isset($this->view->total_rows) ? $this->view->total_rows : 0,
+      'current_page' => $this->view->get_current_page(),
+      'search_api results' => $this->view->query->getSearchApiResults(),
+    );
+    cache_set($cid, $data, $this->table, $this->cache_set_expire($type));
+  }
+
+  /**
+   * Overrides views_plugin_cache::cache_get().
+   *
+   * Additionally stores successfully retrieved results with
+   * search_api_current_search().
+   */
+  public function cache_get($type) {
+    if ($type != 'results') {
+      return parent::cache_get($type);
+    }
+
+    // Values to set: $view->result, $view->total_rows, $view->execute_time,
+    // $view->current_page.
+    if ($cache = cache_get($this->get_results_key(), $this->table)) {
+      $cutoff = $this->cache_expire($type);
+      if (!$cutoff || $cache->created > $cutoff) {
+        $this->view->result = $cache->data['result'];
+        $this->view->total_rows = $cache->data['total_rows'];
+        $this->view->set_current_page($cache->data['current_page']);
+        $this->view->execute_time = 0;
+
+        // Trick Search API into believing a search happened, to make facetting
+        // et al. work.
+        $query = $this->getSearchApiQuery();
+        search_api_current_search($query->getOption('search id'), $query, $cache->data['search_api results']);
+
+        return TRUE;
+      }
+    }
+    return FALSE;
+  }
+
+  /**
+   * Overrides views_plugin_cache::get_results_key().
+   *
+   * Use the Search API query as the main source for the key.
+   */
+  public function get_results_key() {
+    global $user;
+
+    if (!isset($this->_results_key)) {
+      $query = $this->getSearchApiQuery();
+      $query->preExecute();
+      $key_data = array(
+        'query' => $query,
+        'roles' => array_keys($user->roles),
+        'super-user' => $user->uid == 1, // special caching for super user.
+        'language' => $GLOBALS['language']->language,
+        'base_url' => $GLOBALS['base_url'],
+      );
+      // Not sure what gets passed in exposed_info, so better include it. All
+      // other parameters used in the parent method are already reflected in the
+      // Search API query object we use.
+      if (isset($_GET['exposed_info'])) {
+        $key_data[$key] = $_GET[$key];
+      }
+
+      $this->_results_key = $this->view->name . ':' . $this->display->id . ':results:' . md5(serialize($key_data));
+    }
+
+    return $this->_results_key;
+  }
+
+  /**
+   * Get the Search API query object associated with the current view.
+   *
+   * @return SearchApiQueryInterface|null
+   *   The Search API query object associated with the current view; or NULL if
+   *   there is none.
+   */
+  protected function getSearchApiQuery() {
+    if (!isset($this->search_api_query)) {
+      $this->search_api_query = FALSE;
+      if (isset($this->view->query) && $this->view->query instanceof SearchApiViewsQuery) {
+        $this->search_api_query = $this->view->query->getSearchApiQuery();
+      }
+    }
+
+    return $this->search_api_query ? $this->search_api_query : NULL;
+  }
+
+}

+ 94 - 15
contrib/search_api_views/includes/query.inc

@@ -50,6 +50,13 @@ class SearchApiViewsQuery extends views_plugin_query {
    */
   protected $errors;
 
+  /**
+   * Whether to abort the search instead of executing it.
+   *
+   * @var bool
+   */
+  protected $abort = FALSE;
+
   /**
    * The names of all fields whose value is required by a handler.
    *
@@ -85,7 +92,7 @@ class SearchApiViewsQuery extends views_plugin_query {
         $id = substr($base_table, 17);
         $this->index = search_api_index_load($id);
         $this->query = $this->index->query(array(
-          'parse mode' => 'terms',
+          'parse mode' => $this->options['parse_mode'],
         ));
       }
     }
@@ -126,13 +133,19 @@ class SearchApiViewsQuery extends views_plugin_query {
   /**
    * Defines the options used by this query plugin.
    *
-   * Adds an option to bypass access checks.
+   * Adds some access options.
    */
   public function option_definition() {
     return parent::option_definition() + array(
       'search_api_bypass_access' => array(
         'default' => FALSE,
       ),
+      'entity_access' => array(
+        'default' => FALSE,
+      ),
+      'parse_mode' => array(
+        'default' => 'terms',
+      ),
     );
   }
 
@@ -150,6 +163,36 @@ class SearchApiViewsQuery extends views_plugin_query {
       '#description' => t('If the underlying search index has access checks enabled, this option allows to disable them for this view.'),
       '#default_value' => $this->options['search_api_bypass_access'],
     );
+
+    if (entity_get_info($this->index->item_type)) {
+      $form['entity_access'] = array(
+        '#type' => 'checkbox',
+        '#title' => t('Additional access checks on result entities'),
+        '#description' => t("Execute an access check for all result entities. This prevents users from seeing inappropriate content when the index contains stale data, or doesn't provide access checks. However, result counts, paging and other things won't work correctly if results are eliminated in this way, so only use this as a last ressort (and in addition to other checks, if possible)."),
+        '#default_value' => $this->options['entity_access'],
+      );
+    }
+
+    $form['parse_mode'] = array(
+      '#type' => 'select',
+      '#title' => t('Parse mode'),
+      '#description' => t('Choose how the search keys will be parsed.'),
+      '#options' => array(),
+      '#default_value' => $this->options['parse_mode'],
+    );
+    $modes = array();
+    foreach ($this->query->parseModes() as $key => $mode) {
+      $form['parse_mode']['#options'][$key] = $mode['name'];
+      if (!empty($mode['description'])) {
+        $states['visible'][':input[name="query[options][parse_mode]"]']['value'] = $key;
+        $form["parse_mode_{$key}_description"] = array(
+          '#type' => 'item',
+          '#title' => $mode['name'],
+          '#description' => $mode['description'],
+          '#states' => $states,
+        );
+      }
+    }
   }
 
   /**
@@ -173,6 +216,7 @@ class SearchApiViewsQuery extends views_plugin_query {
       // Add a nested filter for each filter group, with its set conjunction.
       foreach ($this->where as $group_id => $group) {
         if (!empty($group['conditions']) || !empty($group['filters'])) {
+          $group += array('type' => 'AND');
           // For filters without a group, we want to always add them directly to
           // the query.
           $filter = ($group_id === '') ? $this->query : $this->query->createFilter($group['type']);
@@ -199,6 +243,21 @@ class SearchApiViewsQuery extends views_plugin_query {
     $view->init_pager();
     $this->pager->query();
 
+    // Views passes sometimes NULL and sometimes the integer 0 for "All" in a
+    // pager. If set to 0 items, a string "0" is passed. Therefore, we unset
+    // the limit if an empty value OTHER than a string "0" was passed.
+    if (!$this->limit && $this->limit !== '0') {
+      $this->limit = NULL;
+    }
+    // Set the range. (We always set this, as there might even be an offset if
+    // all items are shown.)
+    $this->query->range($this->offset, $this->limit);
+
+    // Set the search ID, if it was not already set.
+    if ($this->query->getOption('search id') == get_class($this->query)) {
+      $this->query->setOption('search id', 'search_api_views:' . $view->name . ':' . $view->current_display);
+    }
+
     // Add the "search_api_bypass_access" option to the query, if desired.
     if (!empty($this->options['search_api_bypass_access'])) {
       $this->query->setOption('search_api_bypass_access', TRUE);
@@ -213,7 +272,7 @@ class SearchApiViewsQuery extends views_plugin_query {
    * $view->pager['current_page'].
    */
   public function execute(&$view) {
-    if ($this->errors) {
+    if ($this->errors || $this->abort) {
       if (error_displayable()) {
         foreach ($this->errors as $msg) {
           drupal_set_message(check_plain($msg), 'error');
@@ -227,11 +286,6 @@ class SearchApiViewsQuery extends views_plugin_query {
 
     try {
       $start = microtime(TRUE);
-      // Add range and search ID (if it wasn't already set).
-      $this->query->range($this->offset, $this->limit);
-      if ($this->query->getOption('search id') == get_class($this->query)) {
-        $this->query->setOption('search id', 'search_api_views:' . $view->name . ':' . $view->current_display);
-      }
 
       // Execute the search.
       $results = $this->query->execute();
@@ -258,6 +312,16 @@ class SearchApiViewsQuery extends views_plugin_query {
     }
   }
 
+  /**
+   * Aborts this search query.
+   *
+   * Used by handlers to flag a fatal error which shouldn't be displayed but
+   * still lead to the view returning empty and the search not being executed.
+   */
+  public function abort() {
+    $this->abort = TRUE;
+  }
+
   /**
    * Helper function for adding results to a view in the format expected by the
    * view.
@@ -270,6 +334,12 @@ class SearchApiViewsQuery extends views_plugin_query {
     // First off, we try to gather as much field values as possible without
     // loading any items.
     foreach ($results as $id => $result) {
+      if (!empty($this->options['entity_access'])) {
+        $entity = entity_load($this->index->item_type, array($id));
+        if (!entity_access('view', $this->index->item_type, $entity[$id])) {
+          continue;
+        }
+      }
       $row = array();
 
       // Include the loaded item for this result row, if present, or the item
@@ -353,12 +423,21 @@ class SearchApiViewsQuery extends views_plugin_query {
   public function get_result_entities($results, $relationship = NULL, $field = NULL) {
     list($type, $wrappers) = $this->get_result_wrappers($results, $relationship, $field);
     $return = array();
-    foreach ($wrappers as $id => $wrapper) {
+    foreach ($wrappers as $i => $wrapper) {
       try {
-        $return[$id] = $wrapper->value();
+        // Get the entity ID beforehand for possible watchdog messages.
+        $id = $wrapper->value(array('identifier' => TRUE));
+
+        // Only add results that exist.
+        if ($entity = $wrapper->value()) {
+          $return[$i] = $entity;
+        }
+        else {
+          watchdog('search_api_views', 'The search index returned a reference to an entity with ID @id, which does not exist in the database. Your index may be out of sync and should be rebuilt.', array('@id' => $id), WATCHDOG_ERROR);
+        }
       }
       catch (EntityMetadataWrapperException $e) {
-        // Ignore.
+        watchdog_exception('search_api_views', $e, "%type while trying to load search result entity with ID @id: !message in %function (line %line of %file).", array('@id' => $id), WATCHDOG_ERROR);
       }
     }
     return array($type, $return);
@@ -371,11 +450,11 @@ class SearchApiViewsQuery extends views_plugin_query {
    * query backend.
    */
   public function get_result_wrappers($results, $relationship = NULL, $field = NULL) {
-    $is_entity = (boolean) entity_get_info($this->index->item_type);
+    $entity_type = $this->index->getEntityType();
     $wrappers = array();
     $load_entities = array();
     foreach ($results as $row_index => $row) {
-      if ($is_entity && isset($row->entity)) {
+      if ($entity_type && isset($row->entity)) {
         // If this entity isn't load, register it for pre-loading.
         if (!is_object($row->entity)) {
           $load_entities[$row->entity] = $row_index;
@@ -388,14 +467,14 @@ class SearchApiViewsQuery extends views_plugin_query {
     // If the results are entities, we pre-load them to make use of a multiple
     // load. (Otherwise, each result would be loaded individually.)
     if (!empty($load_entities)) {
-      $entities = entity_load($this->index->item_type, array_keys($load_entities));
+      $entities = entity_load($entity_type, array_keys($load_entities));
       foreach ($entities as $entity_id => $entity) {
         $wrappers[$load_entities[$entity_id]] = $this->index->entityWrapper($entity);
       }
     }
 
     // Apply the relationship, if necessary.
-    $type = $this->index->item_type;
+    $type = $entity_type ? $entity_type : $this->index->item_type;
     $selector_suffix = '';
     if ($field && ($pos = strrpos($field, ':'))) {
       $selector_suffix = substr($field, 0, $pos);

+ 7 - 5
contrib/search_api_views/search_api_views.info

@@ -6,12 +6,13 @@ dependencies[] = views
 core = 7.x
 package = Search
 
-; Views handlers
+; Views handlers/plugins
 files[] = includes/display_facet_block.inc
 files[] = includes/handler_argument.inc
 files[] = includes/handler_argument_fulltext.inc
 files[] = includes/handler_argument_more_like_this.inc
-files[] = includes/handler_argument_text.inc
+files[] = includes/handler_argument_string.inc
+files[] = includes/handler_argument_taxonomy_term.inc
 files[] = includes/handler_filter.inc
 files[] = includes/handler_filter_boolean.inc
 files[] = includes/handler_filter_date.inc
@@ -20,11 +21,12 @@ files[] = includes/handler_filter_language.inc
 files[] = includes/handler_filter_options.inc
 files[] = includes/handler_filter_text.inc
 files[] = includes/handler_sort.inc
+files[] = includes/plugin_cache.inc
 files[] = includes/query.inc
 
-; Information added by drupal.org packaging script on 2013-01-09
-version = "7.x-1.4"
+; Information added by drupal.org packaging script on 2013-09-01
+version = "7.x-1.8"
 core = "7.x"
 project = "search_api"
-datestamp = "1357726719"
+datestamp = "1378025826"
 

+ 10 - 0
contrib/search_api_views/search_api_views.module

@@ -21,9 +21,19 @@ function search_api_views_search_api_index_insert(SearchApiIndex $index) {
  * Implements hook_search_api_index_update().
  */
 function search_api_views_search_api_index_update(SearchApiIndex $index) {
+  // Check whether index was disabled.
   if (!$index->enabled && $index->original->enabled) {
     _search_api_views_index_unavailable($index);
   }
+
+  // Check whether the indexed fields changed.
+  $old_fields = $index->original->options + array('fields' => array());
+  $old_fields = $old_fields['fields'];
+  $new_fields = $index->options + array('fields' => array());
+  $new_fields = $new_fields['fields'];
+  if ($old_fields != $new_fields) {
+    views_invalidate_cache();
+  }
 }
 
 /**

+ 58 - 6
contrib/search_api_views/search_api_views.views.inc

@@ -20,14 +20,20 @@ function search_api_views_views_data() {
         'help' => t('Use the %name search index for filtering and retrieving data.', array('%name' => $index->name)),
         'query class' => 'search_api_views_query',
       );
-      if (isset($entity_types[$index->item_type])) {
+      if (isset($entity_types[$index->getEntityType()])) {
         $table['table'] += array(
-          'entity type' => $index->item_type,
+          'entity type' => $index->getEntityType(),
           'skip entity load' => TRUE,
         );
       }
 
-      $wrapper = $index->entityWrapper(NULL, TRUE);
+      try {
+        $wrapper = $index->entityWrapper(NULL, TRUE);
+      }
+      catch (EntityMetadataWrapperException $e) {
+        watchdog_exception('search_api_views', $e, "%type while retrieving metadata for index %index: !message in %function (line %line of %file).", array('%index' => $index->name), WATCHDOG_WARNING);
+        continue;
+      }
 
       // Add field handlers and relationships provided by the Entity API.
       foreach ($wrapper as $key => $property) {
@@ -105,6 +111,31 @@ function search_api_views_views_data() {
       $table['search_api_views_more_like_this']['title'] = t('More like this');
       $table['search_api_views_more_like_this']['help'] = t('Find similar content.');
       $table['search_api_views_more_like_this']['argument']['handler'] = 'SearchApiViewsHandlerArgumentMoreLikeThis';
+
+      // If there are taxonomy term references indexed in the index, include the
+      // "Indexed taxonomy term fields" contextual filter. We also save for all
+      // fields whether they contain only terms of a certain vocabulary, keying
+      // that information by vocabulary for later ease of use.
+      $vocabulary_fields = array();
+      foreach ($index->getFields() as $key => $field) {
+        if (isset($field['entity_type']) && $field['entity_type'] === 'taxonomy_term') {
+          $field_id = ($pos = strrpos($key, ':')) ? substr($key, $pos + 1) : $key;
+          $field_info = field_info_field($field_id);
+          if (isset($field_info['settings']['allowed_values'][0]['vocabulary'])) {
+            $vocabulary_fields[$field_info['settings']['allowed_values'][0]['vocabulary']][] = $key;
+          }
+          else {
+            $vocabulary_fields[''][] = $key;
+          }
+        }
+      }
+      if ($vocabulary_fields) {
+        $table['search_api_views_taxonomy_term']['group'] = t('Search');
+        $table['search_api_views_taxonomy_term']['title'] = t('Indexed taxonomy term fields');
+        $table['search_api_views_taxonomy_term']['help'] = t('Search in all indexed taxonomy term fields.');
+        $table['search_api_views_taxonomy_term']['argument']['handler'] = 'SearchApiViewsHandlerArgumentTaxonomyTerm';
+        $table['search_api_views_taxonomy_term']['argument']['vocabulary_fields'] = $vocabulary_fields;
+      }
     }
     return $data;
   }
@@ -130,7 +161,7 @@ function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper
   if ($inner_type == 'text') {
     $table[$id] += array(
       'argument' => array(
-        'handler' => 'SearchApiViewsHandlerArgumentText',
+        'handler' => 'SearchApiViewsHandlerArgument',
       ),
       'filter' => array(
         'handler' => 'SearchApiViewsHandlerFilterText',
@@ -142,6 +173,7 @@ function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper
   if ($options = $wrapper->optionsList('view')) {
     $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterOptions';
     $table[$id]['filter']['options'] = $options;
+    $table[$id]['filter']['multi-valued'] = search_api_is_list_type($type);
   }
   elseif ($inner_type == 'boolean') {
     $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilterBoolean';
@@ -153,7 +185,12 @@ function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper
     $table[$id]['filter']['handler'] = 'SearchApiViewsHandlerFilter';
   }
 
-  $table[$id]['argument']['handler'] = 'SearchApiViewsHandlerArgument';
+  if ($inner_type == 'string' || $inner_type == 'uri') {
+    $table[$id]['argument']['handler'] = 'SearchApiViewsHandlerArgumentString';
+  }
+  else {
+    $table[$id]['argument']['handler'] = 'SearchApiViewsHandlerArgument';
+  }
 
   // We can only sort according to single-valued fields.
   if ($type == $inner_type) {
@@ -168,12 +205,27 @@ function _search_api_views_add_handlers($id, array $field, EntityMetadataWrapper
  * Implements hook_views_plugins().
  */
 function search_api_views_views_plugins() {
+  // Collect all base tables provided by this module.
+  $bases = array();
+  foreach (search_api_index_load_multiple(FALSE) as $index) {
+    $bases[] = 'search_api_index_' . $index->machine_name;
+  }
+
   $ret = array(
     'query' => array(
       'search_api_views_query' => array(
         'title' => t('Search API Query'),
         'help' => t('Query will be generated and run using the Search API.'),
-        'handler' => 'SearchApiViewsQuery'
+        'handler' => 'SearchApiViewsQuery',
+      ),
+    ),
+    'cache' => array(
+      'search_api_views_cache' => array(
+        'title' => t('Search-specific'),
+        'help' => t("Cache Search API views. (Other methods probably won't work with search views.)"),
+        'base' => $bases,
+        'handler' => 'SearchApiViewsCache',
+        'uses options' => TRUE,
       ),
     ),
   );

+ 27 - 62
includes/callback.inc

@@ -1,5 +1,13 @@
 <?php
 
+/**
+ * @file
+ * Contains base definitions for data alterations.
+ *
+ * Contains the SearchApiAlterCallbackInterface interface and the
+ * SearchApiAbstractAlterCallback class.
+ */
+
 /**
  * Interface representing a Search API data-alter callback.
  */
@@ -85,10 +93,15 @@ interface SearchApiAlterCallbackInterface {
   public function alterItems(array &$items);
 
   /**
-   * Declare the properties that are (or can be) added to items with this
-   * callback. If a property with this name already exists for an entity it
-   * will be overridden, so keep a clear namespace by prefixing the properties
-   * with the module name if this is not desired.
+   * Declare the properties that are added to items by this callback.
+   *
+   * If one of the specified properties already exists for an entity it will be
+   * overridden, so keep a clear namespace by prefixing the properties with the
+   * module name if this is not desired.
+   *
+   * CAUTION: Since this method is used when calling
+   * SearchApiIndex::getFields(), calling that method from inside propertyInfo()
+   * will lead to a recursion and should therefore be avoided.
    *
    * @see hook_entity_property_info()
    *
@@ -101,8 +114,10 @@ interface SearchApiAlterCallbackInterface {
 }
 
 /**
- * Abstract base class for data-alter callbacks, implementing most methods with
- * sensible defaults.
+ * Abstract base class for data-alter callbacks.
+ *
+ * This class implements most methods with sensible defaults.
+ *
  * Extending classes will at least have to implement the alterItems() method to
  * make this work. If that method adds additional fields to the items,
  * propertyInfo() has to be overridden, too.
@@ -124,12 +139,7 @@ abstract class SearchApiAbstractAlterCallback implements SearchApiAlterCallbackI
   protected $options;
 
   /**
-   * Construct a data-alter callback.
-   *
-   * @param SearchApiIndex $index
-   *   The index whose items will be altered.
-   * @param array $options
-   *   The callback options set for this index.
+   * Implements SearchApiAlterCallbackInterface::__construct().
    */
   public function __construct(SearchApiIndex $index, array $options = array()) {
     $this->index = $index;
@@ -137,64 +147,28 @@ abstract class SearchApiAbstractAlterCallback implements SearchApiAlterCallbackI
   }
 
   /**
-   * Check whether this data-alter callback is applicable for a certain index.
-   *
-   * This can be used for hiding the callback on the index's "Workflow" tab. To
-   * avoid confusion, you should only use criteria that are immutable, such as
-   * the index's entity type. Also, since this is only used for UI purposes, you
-   * should not completely rely on this to ensure certain index configurations
-   * and at least throw an exception with a descriptive error message if this is
-   * violated on runtime.
+   * Implements SearchApiAlterCallbackInterface::supportsIndex().
    *
    * The default implementation always returns TRUE.
-   *
-   * @param SearchApiIndex $index
-   *   The index to check for.
-   *
-   * @return boolean
-   *   TRUE if the callback can run on the given index; FALSE otherwise.
    */
   public function supportsIndex(SearchApiIndex $index) {
     return TRUE;
   }
 
   /**
-   * Display a form for configuring this callback.
-   *
-   * @return array
-   *   A form array for configuring this callback, or FALSE if no configuration
-   *   is possible.
+   * Implements SearchApiAlterCallbackInterface::configurationForm().
    */
   public function configurationForm() {
     return array();
   }
 
   /**
-   * Validation callback for the form returned by configurationForm().
-   *
-   * @param array $form
-   *   The form returned by configurationForm().
-   * @param array $values
-   *   The part of the $form_state['values'] array corresponding to this form.
-   * @param array $form_state
-   *   The complete form state.
+   * Implements SearchApiAlterCallbackInterface::configurationFormValidate().
    */
   public function configurationFormValidate(array $form, array &$values, array &$form_state) { }
 
   /**
-   * Submit callback for the form returned by configurationForm().
-   *
-   * This method should both return the new options and set them internally.
-   *
-   * @param array $form
-   *   The form returned by configurationForm().
-   * @param array $values
-   *   The part of the $form_state['values'] array corresponding to this form.
-   * @param array $form_state
-   *   The complete form state.
-   *
-   * @return array
-   *   The new options array for this callback.
+   * Implements SearchApiAlterCallbackInterface::configurationFormSubmit().
    */
   public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
     $this->options = $values;
@@ -202,16 +176,7 @@ abstract class SearchApiAbstractAlterCallback implements SearchApiAlterCallbackI
   }
 
   /**
-   * Declare the properties that are (or can be) added to items with this
-   * callback. If a property with this name already exists for an entity it
-   * will be overridden, so keep a clear namespace by prefixing the properties
-   * with the module name if this is not desired.
-   *
-   * @see hook_entity_property_info()
-   *
-   * @return array
-   *   Information about all additional properties, as specified by
-   *   hook_entity_property_info() (only the inner "properties" array).
+   * Implements SearchApiAlterCallbackInterface::propertyInfo().
    */
   public function propertyInfo() {
     return array();

+ 11 - 5
includes/callback_add_hierarchy.inc

@@ -231,13 +231,19 @@ class SearchApiAlterAddHierarchy extends SearchApiAbstractAlterCallback {
       }
       return;
     }
-    $v = $wrapper->value(array('identifier' => TRUE));
-    if ($v && !isset($values[$v])) {
-      $values[$v] = $v;
-      if (isset($wrapper->$property) && $wrapper->$property->value()) {
-        $this->extractHierarchy($wrapper->$property, $property, $values);
+    try {
+      $v = $wrapper->value(array('identifier' => TRUE));
+      if ($v && !isset($values[$v])) {
+        $values[$v] = $v;
+        if (isset($wrapper->$property) && $wrapper->value() && $wrapper->$property->value()) {
+          $this->extractHierarchy($wrapper->$property, $property, $values);
+        }
       }
     }
+    catch (EntityMetadataWrapperException $e) {
+      // Some properties like entity_metadata_book_get_properties() throw
+      // exceptions, so we catch them here and ignore the property.
+    }
   }
 
 }

+ 7 - 5
includes/callback_add_viewed_entity.inc

@@ -11,14 +11,16 @@ class SearchApiAlterAddViewedEntity extends SearchApiAbstractAlterCallback {
    * @see SearchApiAlterCallbackInterface::supportsIndex()
    */
   public function supportsIndex(SearchApiIndex $index) {
-    return (bool) entity_get_info($index->item_type);
+    return (bool) $index->getEntityType();
   }
 
   public function configurationForm() {
-    $info = entity_get_info($this->index->item_type);
     $view_modes = array();
-    foreach ($info['view modes'] as $key => $mode) {
-      $view_modes[$key] = $mode['label'];
+    if ($entity_type = $this->index->getEntityType()) {
+      $info = entity_get_info($entity_type);
+      foreach ($info['view modes'] as $key => $mode) {
+        $view_modes[$key] = $mode['label'];
+      }
     }
     $this->options += array('mode' => reset($view_modes));
     if (count($view_modes) > 1) {
@@ -60,7 +62,7 @@ class SearchApiAlterAddViewedEntity extends SearchApiAbstractAlterCallback {
     $original_user = $GLOBALS['user'];
     $GLOBALS['user'] = drupal_anonymous_user();
 
-    $type = $this->index->item_type;
+    $type = $this->index->getEntityType();
     $mode = empty($this->options['mode']) ? 'full' : $this->options['mode'];
     foreach ($items as $id => &$item) {
       // Since we can't really know what happens in entity_view() and render(),

+ 3 - 3
includes/callback_bundle_filter.inc

@@ -7,11 +7,11 @@
 class SearchApiAlterBundleFilter extends SearchApiAbstractAlterCallback {
 
   public function supportsIndex(SearchApiIndex $index) {
-    return ($info = entity_get_info($index->item_type)) && self::hasBundles($info);
+    return $index->getEntityType() && ($info = entity_get_info($index->getEntityType())) && self::hasBundles($info);
   }
 
   public function alterItems(array &$items) {
-    $info = entity_get_info($this->index->item_type);
+    $info = entity_get_info($this->index->getEntityType());
     if (self::hasBundles($info) && isset($this->options['bundles'])) {
       $bundles = array_flip($this->options['bundles']);
       $default = (bool) $this->options['default'];
@@ -25,7 +25,7 @@ class SearchApiAlterBundleFilter extends SearchApiAbstractAlterCallback {
   }
 
   public function configurationForm() {
-    $info = entity_get_info($this->index->item_type);
+    $info = entity_get_info($this->index->getEntityType());
     if (self::hasBundles($info)) {
       $options = array();
       foreach ($info['bundles'] as $bundle => $bundle_info) {

+ 1 - 1
includes/callback_node_access.inc

@@ -22,7 +22,7 @@ class SearchApiAlterNodeAccess extends SearchApiAbstractAlterCallback {
    */
   public function supportsIndex(SearchApiIndex $index) {
     // Currently only node access is supported.
-    return $index->item_type === 'node';
+    return $index->getEntityType() === 'node';
   }
 
   /**

+ 1 - 1
includes/callback_node_status.inc

@@ -22,7 +22,7 @@ class SearchApiAlterNodeStatus extends SearchApiAbstractAlterCallback {
    *   TRUE if the callback can run on the given index; FALSE otherwise.
    */
   public function supportsIndex(SearchApiIndex $index) {
-    return $index->item_type === 'node';
+    return $index->getEntityType() === 'node';
   }
 
   /**

+ 65 - 0
includes/callback_role_filter.inc

@@ -0,0 +1,65 @@
+<?php
+
+/**
+ * @file
+ * Contains the SearchApiAlterRoleFilter class.
+ */
+
+/**
+ * Data alteration that filters out users based on their role.
+ */
+class SearchApiAlterRoleFilter extends SearchApiAbstractAlterCallback {
+
+  /**
+   * Overrides SearchApiAbstractAlterCallback::supportsIndex().
+   *
+   * This plugin only supports indexes containing users.
+   */
+  public function supportsIndex(SearchApiIndex $index) {
+    return $index->getEntityType() == 'user';
+  }
+
+  /**
+   * Implements SearchApiAlterCallbackInterface::alterItems().
+   */
+  public function alterItems(array &$items) {
+    $roles = $this->options['roles'];
+    $default = (bool) $this->options['default'];
+    foreach ($items as $id => $account) {
+      $role_match = (count(array_diff_key($account->roles, $roles)) !== count($account->roles));
+      if ($role_match === $default) {
+        unset($items[$id]);
+      }
+    }
+  }
+
+  /**
+   * Overrides SearchApiAbstractAlterCallback::configurationForm().
+   *
+   * Add option for the roles to include/exclude.
+   */
+  public function configurationForm() {
+    $options = array_map('check_plain', user_roles());
+    $form = array(
+      'default' => array(
+        '#type' => 'radios',
+        '#title' => t('Which users should be indexed?'),
+        '#default_value' => isset($this->options['default']) ? $this->options['default'] : 1,
+        '#options' => array(
+          1 => t('All but those from one of the selected roles'),
+          0 => t('Only those from the selected roles'),
+        ),
+      ),
+      'roles' => array(
+        '#type' => 'select',
+        '#title' => t('Roles'),
+        '#default_value' => isset($this->options['roles']) ? $this->options['roles'] : array(),
+        '#options' => $options,
+        '#size' => min(4, count($options)),
+        '#multiple' => TRUE,
+      ),
+    );
+    return $form;
+  }
+
+}

+ 83 - 6
includes/datasource.inc

@@ -12,6 +12,13 @@
  * They are used for loading items, extracting item data, keeping track of the
  * item status, etc.
  *
+ * Modules providing implementations of this interface that use a different way
+ * (either different table or different method altogether) of keeping track of
+ * indexed/dirty items than SearchApiAbstractDataSourceController should be
+ * aware that indexes' numerical IDs can change due to feature reverts. It is
+ * therefore recommended to use search_api_index_update_datasource(), or similar
+ * code, in a hook_search_api_index_update() implementation.
+ *
  * All methods of the data source may throw exceptions of type
  * SearchApiDataSourceException if any exception or error state is encountered.
  */
@@ -237,6 +244,14 @@ interface SearchApiDataSourceControllerInterface {
    */
   public function getIndexStatus(SearchApiIndex $index);
 
+  /**
+   * Get the entity type of items from this datasource.
+   *
+   * @return string|null
+   *   An entity type string if the items provided by this datasource are
+   *   entities; NULL otherwise.
+   */
+  public function getEntityType();
 }
 
 /**
@@ -264,6 +279,15 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
    */
   protected $type;
 
+  /**
+   * The entity type for this controller instance.
+   *
+   * @var string|null
+   *
+   * @see getEntityType()
+   */
+  protected $entityType = NULL;
+
   /**
    * The info array for the item type, as specified via
    * hook_search_api_item_type_info().
@@ -314,6 +338,21 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
   public function __construct($type) {
     $this->type = $type;
     $this->info = search_api_get_item_type_info($type);
+
+    if (!empty($this->info['entity_type'])) {
+      $this->entityType = $this->info['entity_type'];
+    }
+  }
+
+  /**
+   * Get the entity type of items from this datasource.
+   *
+   * @return string|null
+   *   An entity type string if the items provided by this datasource are
+   *   entities; NULL otherwise.
+   */
+  public function getEntityType() {
+    return $this->entityType;
   }
 
   /**
@@ -333,19 +372,55 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
    */
   public function getMetadataWrapper($item = NULL, array $info = array()) {
     $info += $this->getPropertyInfo();
-    return entity_metadata_wrapper($this->type, $item, $info);
+    return entity_metadata_wrapper($this->entityType ? $this->entityType : $this->type, $item, $info);
   }
 
   /**
-   * Helper method that can be used by subclasses to specify the property
-   * information to use when creating a metadata wrapper.
+   * Get the property info for this item type.
+   *
+   * This is a helper method for getMetadataWrapper() that can be used by
+   * subclasses to specify the property information to use when creating a
+   * metadata wrapper.
+   *
+   * The data structure uses largely the format specified in
+   * hook_entity_property_info(). However, the first level of keys (containing
+   * the entity types) is omitted, and the "property" key is called
+   * "property info" instead. So, an example return value would look like this:
+   *
+   * @code
+   * return array(
+   *   'property info' => array(
+   *     'foo' => array(
+   *       'label' => t('Foo'),
+   *       'type' => 'text',
+   *     ),
+   *     'bar' => array(
+   *       'label' => t('Bar'),
+   *       'type' => 'list<integer>',
+   *     ),
+   *   ),
+   * );
+   * @endcode
+   *
+   * SearchApiExternalDataSourceController::getPropertyInfo() contains a working
+   * example of this method.
+   *
+   * If the item type is an entity type, no additional property information is
+   * required, the method will thus just return an empty array. You can still
+   * use this to append additional properties to the entities, or the like,
+   * though.
    *
    * @return array
-   *   Property information as specified by hook_entity_property_info().
+   *   Property information as specified by entity_metadata_wrapper().
    *
+   * @see getMetadataWrapper()
    * @see hook_entity_property_info()
    */
   protected function getPropertyInfo() {
+    // If this is an entity type, no additional property info is needed.
+    if ($this->entityType) {
+      return array();
+    }
     throw new SearchApiDataSourceException(t('No known property information for type @type.', array('@type' => $this->type)));
   }
 
@@ -689,8 +764,10 @@ abstract class SearchApiAbstractDataSourceController implements SearchApiDataSou
     if ($index->item_type != $this->type) {
       $index_type = search_api_get_item_type_info($index->item_type);
       $index_type = empty($index_type['name']) ? $index->item_type : $index_type['name'];
-      $msg = t('Invalid index @index of type @index_type passed to data source controller for type @this_type.',
-          array('@index' => $index->name, '@index_type' => $index_type, '@this_type' => $this->info['name']));
+      $msg = t(
+        'Invalid index @index of type @index_type passed to data source controller for type @this_type.',
+        array('@index' => $index->name, '@index_type' => $index_type, '@this_type' => $this->info['name'])
+      );
       throw new SearchApiDataSourceException($msg);
     }
   }

+ 10 - 10
includes/datasource_entity.inc

@@ -20,8 +20,8 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
    *     search_api_field_types(). List types ("list<*>") are not allowed.
    */
   public function getIdFieldInfo() {
-    $info = entity_get_info($this->type);
-    $properties = entity_get_property_info($this->type);
+    $info = entity_get_info($this->entityType);
+    $properties = entity_get_property_info($this->entityType);
     if (empty($info['entity keys']['id'])) {
       throw new SearchApiDataSourceException(t("Entity type @type doesn't specify an ID key.", array('@type' => $info['label'])));
     }
@@ -52,7 +52,7 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
    *   The loaded items, keyed by ID.
    */
   public function loadItems(array $ids) {
-    $items = entity_load($this->type, $ids);
+    $items = entity_load($this->entityType, $ids);
     // If some items couldn't be loaded, remove them from tracking.
     if (count($items) != count($ids)) {
       $ids = array_flip($ids);
@@ -80,7 +80,7 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
    * @see entity_metadata_wrapper()
    */
   public function getMetadataWrapper($item = NULL, array $info = array()) {
-    return entity_metadata_wrapper($this->type, $item, $info);
+    return entity_metadata_wrapper($this->entityType, $item, $info);
   }
 
   /**
@@ -93,7 +93,7 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
    *   Either the unique ID of the item, or NULL if none is available.
    */
   public function getItemId($item) {
-    $id = entity_id($this->type, $item);
+    $id = entity_id($this->entityType, $item);
     return $id ? $id : NULL;
   }
 
@@ -107,7 +107,7 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
    *   Either a human-readable label for the item, or NULL if none is available.
    */
   public function getItemLabel($item) {
-    $label = entity_label($this->type, $item);
+    $label = entity_label($this->entityType, $item);
     return $label ? $label : NULL;
   }
 
@@ -123,7 +123,7 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
    *   item has no URL of its own.
    */
   public function getItemUrl($item) {
-    if ($this->type == 'file') {
+    if ($this->entityType == 'file') {
       return array(
         'path' => file_create_url($item->uri),
         'options' => array(
@@ -132,7 +132,7 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
         ),
       );
     }
-    $url = entity_uri($this->type, $item);
+    $url = entity_uri($this->entityType, $item);
     return $url ? $url : NULL;
   }
 
@@ -158,7 +158,7 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
     // all items again without any key conflicts.
     $this->stopTracking($indexes);
 
-    $entity_info = entity_get_info($this->type);
+    $entity_info = entity_get_info($this->entityType);
 
     if (!empty($entity_info['base table'])) {
       // Use a subselect, which will probably be much faster than entity_load().
@@ -200,7 +200,7 @@ class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceCon
    *   An array containing all item IDs for this type.
    */
   protected function getAllItemIds() {
-    return array_keys(entity_load($this->type));
+    return array_keys(entity_load($this->entityType));
   }
 
 }

+ 3 - 12
includes/datasource_external.inc

@@ -19,9 +19,7 @@
  * will only have to specify some property information in getPropertyInfo(). If
  * you have a custom service class which already returns the extracted fields
  * with the search results, you will only have to provide a label and a type for
- * each field. To make this use case easier, there is also a
- * getFieldInformation() method which you can implement instead of directly
- * implementing getPropertyInfo().
+ * each field.
  */
 class SearchApiExternalDataSourceController extends SearchApiAbstractDataSourceController {
 
@@ -61,16 +59,9 @@ class SearchApiExternalDataSourceController extends SearchApiAbstractDataSourceC
   }
 
   /**
-   * Helper method that can be used by subclasses to specify the property
-   * information to use when creating a metadata wrapper.
+   * Overrides SearchApiAbstractDataSourceController::getPropertyInfo().
    *
-   * For most use cases, you will have to override this method to provide the
-   * real property information for your item type.
-   *
-   * @return array
-   *   Property information as specified by hook_entity_property_info().
-   *
-   * @see hook_entity_property_info()
+   * Only returns a single string ID field.
    */
   protected function getPropertyInfo() {
     $info['property info']['id'] = array(

+ 184 - 126
includes/index_entity.inc

@@ -43,6 +43,15 @@ class SearchApiIndex extends Entity {
    */
   protected $added_properties = NULL;
 
+  /**
+   * Static cache for the results of getFields().
+   *
+   * Can be accessed as follows: $this->fields[$only_indexed][$get_additional].
+   *
+   * @var array
+   */
+  protected $fields = array();
+
   /**
    * An array containing two arrays.
    *
@@ -192,7 +201,11 @@ class SearchApiIndex extends Entity {
       if ($server->enabled) {
         $server->removeIndex($this);
       }
-      else {
+      // Once the index is deleted, servers won't be able to tell whether it was
+      // read-only. Therefore, we prefer to err on the safe side and don't call
+      // the server method at all if the index is read-only and the server
+      // currently disabled.
+      elseif (empty($this->read_only)) {
         $tasks = variable_get('search_api_tasks', array());
         $tasks[$server->machine_name][$this->machine_name] = array('remove');
         variable_set('search_api_tasks', $tasks);
@@ -356,6 +369,17 @@ class SearchApiIndex extends Entity {
     return $this->datasource;
   }
 
+  /**
+   * Get the entity type of items in this index.
+   *
+   * @return string|null
+   *   An entity type string if the items in this index are entities; NULL
+   *   otherwise.
+   */
+  public function getEntityType() {
+    return $this->datasource()->getEntityType();
+  }
+
   /**
    * Get the server this index lies on.
    *
@@ -528,8 +552,8 @@ class SearchApiIndex extends Entity {
           'options list' => 'entity_metadata_language_list',
         ),
       );
-      // We use the reverse order here so the hierarchy for overwriting property infos is the same
-      // as for actually overwriting the properties.
+      // We use the reverse order here so the hierarchy for overwriting property
+      // infos is the same as for actually overwriting the properties.
       foreach (array_reverse($this->getAlterCallbacks()) as $callback) {
         $props = $callback->propertyInfo();
         if ($props) {
@@ -543,16 +567,14 @@ class SearchApiIndex extends Entity {
     return $property_info;
   }
 
-   /**
-   * Fills the $processors array for use by the pre-/postprocessing functions.
+  /**
+   * Loads all enabled data alterations for this index in proper order.
    *
-   * @return SearchApiIndex
-   *   The called object.
    * @return array
    *   All enabled callbacks for this index, as SearchApiAlterCallbackInterface
    *   objects.
    */
-  protected function getAlterCallbacks() {
+  public function getAlterCallbacks() {
     if (isset($this->callbacks)) {
       return $this->callbacks;
     }
@@ -585,11 +607,13 @@ class SearchApiIndex extends Entity {
   }
 
   /**
+   * Loads all enabled processors for this index in proper order.
+   *
    * @return array
    *   All enabled processors for this index, as SearchApiProcessorInterface
    *   objects.
    */
-  protected function getProcessors() {
+  public function getProcessors() {
     if (isset($this->processors)) {
       return $this->processors;
     }
@@ -721,140 +745,160 @@ class SearchApiIndex extends Entity {
    *   "additional fields" key.
    */
   public function getFields($only_indexed = TRUE, $get_additional = FALSE) {
-    $fields = empty($this->options['fields']) ? array() : $this->options['fields'];
-    $wrapper = $this->entityWrapper();
-    $additional = array();
-    $entity_types = entity_get_info();
-
-    // First we need all already added prefixes.
-    $added = ($only_indexed || empty($this->options['additional fields'])) ? array() : $this->options['additional fields'];
-    foreach (array_keys($fields) as $key) {
-      $len = strlen($key) + 1;
-      $pos = $len;
-      // The third parameter ($offset) to strrpos has rather weird behaviour,
-      // necessitating this rather awkward code. It will iterate over all
-      // prefixes of each field, beginning with the longest, adding all of them
-      // to $added until one is encountered that was already added (which means
-      // all shorter ones will have already been added, too).
-      while ($pos = strrpos($key, ':', $pos - $len)) {
-        $prefix = substr($key, 0, $pos);
-        if (isset($added[$prefix])) {
-          break;
-        }
-        $added[$prefix] = $prefix;
+    $only_indexed = $only_indexed ? 1 : 0;
+    $get_additional = $get_additional ? 1 : 0;
+
+    // First, try the static cache and the persistent cache bin.
+    if (empty($this->fields[$only_indexed][$get_additional])) {
+      $cid = $this->getCacheId() . "-$only_indexed-$get_additional";
+      $cache = cache_get($cid);
+      if ($cache) {
+        $this->fields[$only_indexed][$get_additional] = $cache->data;
       }
     }
 
-    // Then we walk through all properties and look if they are already
-    // contained in one of the arrays.
-    // Since this uses an iterative instead of a recursive approach, it is a bit
-    // complicated, with three arrays tracking the current depth.
-
-    // A wrapper for a specific field name prefix, e.g. 'user:' mapped to the user wrapper
-    $wrappers = array('' => $wrapper);
-    // Display names for the prefixes
-    $prefix_names = array('' => '');
-      // The list nesting level for entities with a certain prefix
-    $nesting_levels = array('' => 0);
-
-    $types = search_api_default_field_types();
-    $flat = array();
-    while ($wrappers) {
-      foreach ($wrappers as $prefix => $wrapper) {
-        $prefix_name = $prefix_names[$prefix];
-        // Deal with lists of entities.
-        $nesting_level = $nesting_levels[$prefix];
-        $type_prefix = str_repeat('list<', $nesting_level);
-        $type_suffix = str_repeat('>', $nesting_level);
-        if ($nesting_level) {
-          $info = $wrapper->info();
-          // The real nesting level of the wrapper, not the accumulated one.
-          $level = search_api_list_nesting_level($info['type']);
-          for ($i = 0; $i < $level; ++$i) {
-            $wrapper = $wrapper[0];
+    // Otherwise, we have to compute the result.
+    if (empty($this->fields[$only_indexed][$get_additional])) {
+      $fields = empty($this->options['fields']) ? array() : $this->options['fields'];
+      $wrapper = $this->entityWrapper();
+      $additional = array();
+      $entity_types = entity_get_info();
+
+      // First we need all already added prefixes.
+      $added = ($only_indexed || empty($this->options['additional fields'])) ? array() : $this->options['additional fields'];
+      foreach (array_keys($fields) as $key) {
+        $len = strlen($key) + 1;
+        $pos = $len;
+        // The third parameter ($offset) to strrpos has rather weird behaviour,
+        // necessitating this rather awkward code. It will iterate over all
+        // prefixes of each field, beginning with the longest, adding all of them
+        // to $added until one is encountered that was already added (which means
+        // all shorter ones will have already been added, too).
+        while ($pos = strrpos($key, ':', $pos - $len)) {
+          $prefix = substr($key, 0, $pos);
+          if (isset($added[$prefix])) {
+            break;
           }
+          $added[$prefix] = $prefix;
         }
-        // Now look at all properties.
-        foreach ($wrapper as $property => $value) {
-          $info = $value->info();
-          // We hide the complexity of multi-valued types from the user here.
-          $type = search_api_extract_inner_type($info['type']);
-          // Treat Entity API type "token" as our "string" type.
-          // Also let text fields with limited options be of type "string" by default.
-          if ($type == 'token' || ($type == 'text' && !empty($info['options list']))) {
-            // Inner type is changed to "string".
-            $type = 'string';
-            // Set the field type accordingly.
-            $info['type'] = search_api_nest_type('string', $info['type']);
+      }
+
+      // Then we walk through all properties and look if they are already
+      // contained in one of the arrays.
+      // Since this uses an iterative instead of a recursive approach, it is a bit
+      // complicated, with three arrays tracking the current depth.
+
+      // A wrapper for a specific field name prefix, e.g. 'user:' mapped to the user wrapper
+      $wrappers = array('' => $wrapper);
+      // Display names for the prefixes
+      $prefix_names = array('' => '');
+        // The list nesting level for entities with a certain prefix
+      $nesting_levels = array('' => 0);
+
+      $types = search_api_default_field_types();
+      $flat = array();
+      while ($wrappers) {
+        foreach ($wrappers as $prefix => $wrapper) {
+          $prefix_name = $prefix_names[$prefix];
+          // Deal with lists of entities.
+          $nesting_level = $nesting_levels[$prefix];
+          $type_prefix = str_repeat('list<', $nesting_level);
+          $type_suffix = str_repeat('>', $nesting_level);
+          if ($nesting_level) {
+            $info = $wrapper->info();
+            // The real nesting level of the wrapper, not the accumulated one.
+            $level = search_api_list_nesting_level($info['type']);
+            for ($i = 0; $i < $level; ++$i) {
+              $wrapper = $wrapper[0];
+            }
           }
-          $info['type'] = $type_prefix . $info['type'] . $type_suffix;
-          $key = $prefix . $property;
-          if ((isset($types[$type]) || isset($entity_types[$type])) && (!$only_indexed || !empty($fields[$key]))) {
-            if (!empty($fields[$key])) {
-              // This field is already known in the index configuration.
-              $flat[$key] = $fields[$key] + array(
-                'name' => $prefix_name . $info['label'],
-                'description' => empty($info['description']) ? NULL : $info['description'],
-                'boost' => '1.0',
-                'indexed' => TRUE,
-              );
-              // Update the type and its nesting level for non-entity properties.
-              if (!isset($entity_types[$type])) {
-                $flat[$key]['type'] = search_api_nest_type(search_api_extract_inner_type($flat[$key]['type']), $info['type']);
-                if (isset($flat[$key]['real_type'])) {
-                  $real_type = search_api_extract_inner_type($flat[$key]['real_type']);
-                  $flat[$key]['real_type'] = search_api_nest_type($real_type, $info['type']);
+          // Now look at all properties.
+          foreach ($wrapper as $property => $value) {
+            $info = $value->info();
+            // We hide the complexity of multi-valued types from the user here.
+            $type = search_api_extract_inner_type($info['type']);
+            // Treat Entity API type "token" as our "string" type.
+            // Also let text fields with limited options be of type "string" by default.
+            if ($type == 'token' || ($type == 'text' && !empty($info['options list']))) {
+              // Inner type is changed to "string".
+              $type = 'string';
+              // Set the field type accordingly.
+              $info['type'] = search_api_nest_type('string', $info['type']);
+            }
+            $info['type'] = $type_prefix . $info['type'] . $type_suffix;
+            $key = $prefix . $property;
+            if ((isset($types[$type]) || isset($entity_types[$type])) && (!$only_indexed || !empty($fields[$key]))) {
+              if (!empty($fields[$key])) {
+                // This field is already known in the index configuration.
+                $flat[$key] = $fields[$key] + array(
+                  'name' => $prefix_name . $info['label'],
+                  'description' => empty($info['description']) ? NULL : $info['description'],
+                  'boost' => '1.0',
+                  'indexed' => TRUE,
+                );
+                // Update the type and its nesting level for non-entity properties.
+                if (!isset($entity_types[$type])) {
+                  $flat[$key]['type'] = search_api_nest_type(search_api_extract_inner_type($flat[$key]['type']), $info['type']);
+                  if (isset($flat[$key]['real_type'])) {
+                    $real_type = search_api_extract_inner_type($flat[$key]['real_type']);
+                    $flat[$key]['real_type'] = search_api_nest_type($real_type, $info['type']);
+                  }
                 }
               }
+              else {
+                $flat[$key] = array(
+                  'name'    => $prefix_name . $info['label'],
+                  'description' => empty($info['description']) ? NULL : $info['description'],
+                  'type'    => $info['type'],
+                  'boost' => '1.0',
+                  'indexed' => FALSE,
+                );
+              }
+              if (isset($entity_types[$type])) {
+                $base_type = isset($entity_types[$type]['entity keys']['name']) ? 'string' : 'integer';
+                $flat[$key]['type'] = search_api_nest_type($base_type, $info['type']);
+                $flat[$key]['entity_type'] = $type;
+              }
             }
-            else {
-              $flat[$key] = array(
-                'name'    => $prefix_name . $info['label'],
-                'description' => empty($info['description']) ? NULL : $info['description'],
-                'type'    => $info['type'],
-                'boost' => '1.0',
-                'indexed' => FALSE,
-              );
-            }
-            if (isset($entity_types[$type])) {
-              $base_type = isset($entity_types[$type]['entity keys']['name']) ? 'string' : 'integer';
-              $flat[$key]['type'] = search_api_nest_type($base_type, $info['type']);
-              $flat[$key]['entity_type'] = $type;
-            }
-          }
-          if (empty($types[$type])) {
-            if (isset($added[$key])) {
-              // Visit this entity/struct in a later iteration.
-              $wrappers[$key . ':'] = $value;
-              $prefix_names[$key . ':'] = $prefix_name . $info['label'] . ' » ';
-              $nesting_levels[$key . ':'] = search_api_list_nesting_level($info['type']);
-            }
-            else {
-              $name = $prefix_name . $info['label'];
-              // Add machine names to discern fields with identical labels.
-              if (isset($used_names[$name])) {
-                if ($used_names[$name] !== FALSE) {
-                  $additional[$used_names[$name]] .= ' [' . $used_names[$name] . ']';
-                  $used_names[$name] = FALSE;
+            if (empty($types[$type])) {
+              if (isset($added[$key])) {
+                // Visit this entity/struct in a later iteration.
+                $wrappers[$key . ':'] = $value;
+                $prefix_names[$key . ':'] = $prefix_name . $info['label'] . ' » ';
+                $nesting_levels[$key . ':'] = search_api_list_nesting_level($info['type']);
+              }
+              else {
+                $name = $prefix_name . $info['label'];
+                // Add machine names to discern fields with identical labels.
+                if (isset($used_names[$name])) {
+                  if ($used_names[$name] !== FALSE) {
+                    $additional[$used_names[$name]] .= ' [' . $used_names[$name] . ']';
+                    $used_names[$name] = FALSE;
+                  }
+                  $name .= ' [' . $key . ']';
                 }
-                $name .= ' [' . $key . ']';
+                $additional[$key] = $name;
+                $used_names[$name] = $key;
               }
-              $additional[$key] = $name;
-              $used_names[$name] = $key;
             }
           }
+          unset($wrappers[$prefix]);
         }
-        unset($wrappers[$prefix]);
       }
-    }
 
-    if (!$get_additional) {
-      return $flat;
+      if (!$get_additional) {
+        $this->fields[$only_indexed][$get_additional] = $flat;
+      }
+      else {
+        $options = array();
+        $options['fields'] = $flat;
+        $options['additional fields'] = $additional;
+        $this->fields[$only_indexed][$get_additional] =  $options;
+      }
+      cache_set($cid, $this->fields[$only_indexed][$get_additional]);
     }
-    $options = array();
-    $options['fields'] = $flat;
-    $options['additional fields'] = $additional;
-    return $options;
+
+    return $this->fields[$only_indexed][$get_additional];
   }
 
   /**
@@ -881,6 +925,19 @@ class SearchApiIndex extends Entity {
     return $this->fulltext_fields[$i];
   }
 
+  /**
+   * Get the cache ID prefix used for this index's caches.
+   *
+   * @param $type
+   *   The type of cache. Currently only "fields" is used.
+   *
+   * @return
+   *   The cache ID (prefix) for this index's caches.
+   */
+  public function getCacheId($type = 'fields') {
+    return 'search_api:index-' . $this->machine_name . '--' . $type;
+  }
+
   /**
    * Helper function for creating an entity metadata wrapper appropriate for
    * this index.
@@ -930,6 +987,7 @@ class SearchApiIndex extends Entity {
     $this->callbacks = NULL;
     $this->processors = NULL;
     $this->added_properties = NULL;
+    $this->fields = array();
     $this->fulltext_fields = array();
   }
 

+ 46 - 4
includes/processor.inc

@@ -187,7 +187,7 @@ abstract class SearchApiAbstractProcessor implements SearchApiProcessorInterface
   public function configurationFormValidate(array $form, array &$values, array &$form_state) {
     $fields = array_filter($values['fields']);
     if ($fields) {
-      $fields = array_combine($fields, array_fill(0, count($fields), TRUE));
+      $fields = array_fill_keys($fields, TRUE);
     }
     $values['fields'] = $fields;
   }
@@ -272,8 +272,14 @@ abstract class SearchApiAbstractProcessor implements SearchApiProcessorInterface
       $this->processFieldValue($value);
     }
     if (is_array($value)) {
-      $type = 'tokens';
-      $value = $this->normalizeTokens($value);
+      // Don't tokenize non-fulltext content!
+      if (in_array($type, array('text', 'tokens'))) {
+        $type = 'tokens';
+        $value = $this->normalizeTokens($value);
+      }
+      else {
+        $value = $this->implodeTokens($value);
+      }
     }
   }
 
@@ -305,6 +311,32 @@ abstract class SearchApiAbstractProcessor implements SearchApiProcessorInterface
     return $ret;
   }
 
+  /**
+   * Internal helper function for imploding tokens into a single string.
+   *
+   * @param array $tokens
+   *   The tokens array to implode.
+   *
+   * @return string
+   *   The text data from the tokens concatenated into a single string.
+   */
+  protected function implodeTokens(array $tokens) {
+    $ret = array();
+    foreach ($tokens as $token) {
+      if (empty($token['value']) && !is_numeric($token['value'])) {
+        // Filter out empty tokens.
+        continue;
+      }
+      if (is_array($token['value'])) {
+        $ret[] = $this->implodeTokens($token['value']);
+      }
+      else {
+        $ret[] = $token['value'];
+      }
+    }
+    return implode(' ', $ret);
+  }
+
   /**
    * Method for preprocessing search keys.
    */
@@ -329,10 +361,20 @@ abstract class SearchApiAbstractProcessor implements SearchApiProcessorInterface
    */
   protected function processFilters(array &$filters) {
     $fields = $this->index->options['fields'];
-    foreach ($filters as &$f) {
+    foreach ($filters as $key => &$f) {
       if (is_array($f)) {
         if (isset($fields[$f[0]]) && $this->testField($f[0], $fields[$f[0]])) {
+          // We want to allow processors also to easily remove complete filters.
+          // However, we can't use empty() or the like, as that would sort out
+          // filters for 0 or NULL. So we specifically check only for the empty
+          // string, and we also make sure the filter value was actually changed
+          // by storing whether it was empty before.
+          $empty_string = $f[1] === '';
           $this->processFilterValue($f[1]);
+
+          if ($f[1] === '' && !$empty_string) {
+            unset($filters[$key]);
+          }
         }
       }
       else {

+ 401 - 0
includes/processor_highlight.inc

@@ -0,0 +1,401 @@
+<?php
+
+/**
+ * @file
+ * Contains the SearchApiHighlight class.
+ */
+
+/**
+ * Processor for highlighting search results.
+ */
+class SearchApiHighlight extends SearchApiAbstractProcessor {
+
+  /**
+   * PREG regular expression for a word boundary.
+   *
+   * We highlight around non-indexable or CJK characters.
+   *
+   * @var string
+   */
+  protected static $boundary;
+
+  /**
+   * PREG regular expression for splitting words.
+   *
+   * We highlight around non-indexable or CJK characters.
+   *
+   * @var string
+   */
+  protected static $split;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(SearchApiIndex $index, array $options = array()) {
+    parent::__construct($index, $options);
+
+    $cjk = '\x{1100}-\x{11FF}\x{3040}-\x{309F}\x{30A1}-\x{318E}' .
+        '\x{31A0}-\x{31B7}\x{31F0}-\x{31FF}\x{3400}-\x{4DBF}\x{4E00}-\x{9FCF}' .
+        '\x{A000}-\x{A48F}\x{A4D0}-\x{A4FD}\x{A960}-\x{A97F}\x{AC00}-\x{D7FF}' .
+        '\x{F900}-\x{FAFF}\x{FF21}-\x{FF3A}\x{FF41}-\x{FF5A}\x{FF66}-\x{FFDC}' .
+        '\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}';
+    self::$boundary = '(?:(?<=[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . $cjk . '])|(?=[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . $cjk . ']))';
+    self::$split = '/[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . $cjk . ']+/iu';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function configurationForm() {
+    $this->options += array(
+      'prefix' => '<strong>',
+      'suffix' => '</strong>',
+      'excerpt' => TRUE,
+      'excerpt_length' => 256,
+      'highlight' => 'always',
+    );
+
+    $form['prefix'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Highlighting prefix'),
+      '#description' => t('Text/HTML that will be prepended to all occurrences of search keywords in highlighted text.'),
+      '#default_value' => $this->options['prefix'],
+    );
+    $form['suffix'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Highlighting suffix'),
+      '#description' => t('Text/HTML that will be appended to all occurrences of search keywords in highlighted text.'),
+      '#default_value' => $this->options['suffix'],
+    );
+    $form['excerpt'] = array(
+      '#type' => 'checkbox',
+      '#title' => t('Create excerpt'),
+      '#description' => t('When enabled, an excerpt will be created for searches with keywords, containing all occurrences of keywords in a fulltext field.'),
+      '#default_value' => $this->options['excerpt'],
+    );
+    $form['excerpt_length'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Excerpt length'),
+      '#description' => t('The requested length of the excerpt, in characters.'),
+      '#default_value' => $this->options['excerpt_length'],
+      '#element_validate' => array('element_validate_integer_positive'),
+      '#states' => array(
+        'visible' => array(
+          '#edit-processors-search-api-highlighting-settings-excerpt' => array(
+            'checked' => TRUE,
+          ),
+        ),
+      ),
+    );
+    $form['highlight'] = array(
+      '#type' => 'select',
+      '#title' => t('Highlight returned field data'),
+      '#description' => t('Select whether returned fields should be highlighted.'),
+      '#options' => array(
+        'always' => t('Always'),
+        'server' => t('If the server returns fields'),
+        'never' => t('Never'),
+      ),
+      '#default_value' => $this->options['highlight'],
+    );
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function configurationFormValidate(array $form, array &$values, array &$form_state) {
+    // Overridden so $form['fields'] is not checked.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function postprocessSearchResults(array &$response, SearchApiQuery $query) {
+    if (!$response['result count'] || !($keys = $this->getKeywords($query))) {
+      return;
+    }
+
+    foreach ($response['results'] as $id => &$result) {
+      if ($this->options['excerpt']) {
+        $text = array();
+        $fields = $this->getFulltextFields($response['results'], $id);
+        foreach ($fields as $data) {
+          if (is_array($data)) {
+            $text = array_merge($text, $data);
+          }
+          else {
+            $text[] = $data;
+          }
+        }
+        $result['excerpt'] = $this->createExcerpt(implode("\n\n", $text), $keys);
+      }
+      if ($this->options['highlight'] != 'never') {
+        $fields = $this->getFulltextFields($response['results'], $id, $this->options['highlight'] == 'always');
+        foreach ($fields as $field => $data) {
+          if (is_array($data)) {
+            foreach ($data as $i => $text) {
+              $result['fields'][$field][$i] = $this->highlightField($text, $keys);
+            }
+          }
+          else {
+            $result['fields'][$field] = $this->highlightField($data, $keys);
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * Retrieves the fulltext data of a result.
+   *
+   * @param array $result
+   *   All results returned in the search.
+   * @param int|string $i
+   *   The index in the results array of the result whose data should be
+   *   returned.
+   * @param bool $load
+   *   TRUE if the item should be loaded if necessary, FALSE if only fields
+   *   already returned in the results should be used.
+   *
+   * @return array
+   *   An array containing fulltext field names mapped to the text data
+   *   contained in them for the given result.
+   */
+  protected function getFulltextFields(array &$results, $i, $load = TRUE) {
+    $data = array();
+    // Act as if $load is TRUE if we have a loaded item.
+    $load |= !empty($result['entity']);
+
+    $result = &$results[$i];
+    $result += array('fields' => array());
+    $fulltext_fields = $this->index->getFulltextFields();
+    // We only need detailed fields data if $load is TRUE.
+    $fields = $load ? $this->index->getFields() : array();
+    $needs_extraction = array();
+    foreach ($fulltext_fields as $field) {
+      if (array_key_exists($field, $result['fields'])) {
+        $data[$field] = $result['fields'][$field];
+      }
+      elseif ($load) {
+        $needs_extraction[$field] = $fields[$field];
+      }
+    }
+
+    if (!$needs_extraction) {
+      return $data;
+    }
+
+    if (empty($result['entity'])) {
+      $items = $this->index->loadItems(array_keys($results));
+      foreach ($items as $id => $item) {
+        $results[$id]['entity'] = $item;
+      }
+    }
+    // If we still don't have a loaded item, we should stop trying.
+    if (empty($result['entity'])) {
+      return $data;
+    }
+    $wrapper = $this->index->entityWrapper($result['entity'], FALSE);
+    $extracted = search_api_extract_fields($wrapper, $needs_extraction);
+
+    foreach ($extracted as $field => $info) {
+      if (isset($info['value'])) {
+        $data[$field] = $info['value'];
+      }
+    }
+
+    return $data;
+  }
+
+  /**
+   * Extracts the positive keywords used in a search query.
+   *
+   * @param SearchApiQuery $query
+   *   The query from which to extract the keywords.
+   *
+   * @return array
+   *   An array of all unique positive keywords used in the query.
+   */
+  protected function getKeywords(SearchApiQuery $query) {
+    $keys = $query->getKeys();
+    if (!$keys) {
+      return array();
+    }
+    if (is_array($keys)) {
+      return $this->flattenKeysArray($keys);
+    }
+
+    $keywords = preg_split(self::$split, $keys);
+    // Assure there are no duplicates. (This is actually faster than
+    // array_unique() by a factor of 3 to 4.)
+    $keywords = drupal_map_assoc(array_filter($keywords));
+    // Remove quotes from keywords.
+    foreach ($keywords as $key) {
+      $keywords[$key] = trim($key, "'\"");
+    }
+    return drupal_map_assoc(array_filter($keywords));
+  }
+
+  /**
+   * Extracts the positive keywords from a keys array.
+   *
+   * @param array $keys
+   *   A search keys array, as specified by SearchApiQueryInterface::getKeys().
+   *
+   * @return array
+   *   An array of all unique positive keywords contained in the keys.
+   */
+  protected function flattenKeysArray(array $keys) {
+    if (!empty($keys['#negation'])) {
+      return array();
+    }
+
+    $keywords = array();
+    foreach ($keys as $i => $key) {
+      if (!element_child($i)) {
+        continue;
+      }
+      if (is_array($key)) {
+        $keywords += $this->flattenKeysArray($key);
+      }
+      else {
+        $keywords[$key] = $key;
+      }
+    }
+
+    return $keywords;
+  }
+
+  /**
+   * Returns snippets from a piece of text, with certain keywords highlighted.
+   *
+   * Largely copied from search_excerpt().
+   *
+   * @param string $text
+   *   The text to extract fragments from.
+   * @param array $keys
+   *   Search keywords entered by the user.
+   *
+   * @return string
+   *   A string containing HTML for the excerpt.
+   */
+  protected function createExcerpt($text, array $keys) {
+    // Prepare text by stripping HTML tags and decoding HTML entities.
+    $text = strip_tags(str_replace(array('<', '>'), array(' <', '> '), $text));
+    $text = ' ' . decode_entities($text);
+
+    // Extract fragments around keywords.
+    // First we collect ranges of text around each keyword, starting/ending
+    // at spaces, trying to get to the requested length.
+    // If the sum of all fragments is too short, we look for second occurrences.
+    $ranges = array();
+    $included = array();
+    $foundkeys = array();
+    $length = 0;
+    $workkeys = $keys;
+    while ($length < $this->options['excerpt_length'] && count($workkeys)) {
+      foreach ($workkeys as $k => $key) {
+        if ($length >= $this->options['excerpt_length']) {
+          break;
+        }
+        // Remember occurrence of key so we can skip over it if more occurrences
+        // are desired.
+        if (!isset($included[$key])) {
+          $included[$key] = 0;
+        }
+        // Locate a keyword (position $p, always >0 because $text starts with a
+        // space).
+        $p = 0;
+        if (preg_match('/' . self::$boundary . $key . self::$boundary . '/iu', $text, $match, PREG_OFFSET_CAPTURE, $included[$key])) {
+          $p = $match[0][1];
+        }
+        // Now locate a space in front (position $q) and behind it (position $s),
+        // leaving about 60 characters extra before and after for context.
+        // Note that a space was added to the front and end of $text above.
+        if ($p) {
+          if (($q = strpos(' ' . $text, ' ', max(0, $p - 61))) !== FALSE) {
+            $end = substr($text . ' ', $p, 80);
+            if (($s = strrpos($end, ' ')) !== FALSE) {
+              // Account for the added spaces.
+              $q = max($q - 1, 0);
+              $s = min($s, strlen($end) - 1);
+              $ranges[$q] = $p + $s;
+              $length += $p + $s - $q;
+              $included[$key] = $p + 1;
+            }
+            else {
+              unset($workkeys[$k]);
+            }
+          }
+          else {
+            unset($workkeys[$k]);
+          }
+        }
+        else {
+          unset($workkeys[$k]);
+        }
+      }
+    }
+
+    if (count($ranges) == 0) {
+      // We didn't find any keyword matches, so just return NULL.
+      return NULL;
+    }
+
+    // Sort the text ranges by starting position.
+    ksort($ranges);
+
+    // Now we collapse overlapping text ranges into one. The sorting makes it O(n).
+    $newranges = array();
+    foreach ($ranges as $from2 => $to2) {
+      if (!isset($from1)) {
+        $from1 = $from2;
+        $to1 = $to2;
+        continue;
+      }
+      if ($from2 <= $to1) {
+        $to1 = max($to1, $to2);
+      }
+      else {
+        $newranges[$from1] = $to1;
+        $from1 = $from2;
+        $to1 = $to2;
+      }
+    }
+    $newranges[$from1] = $to1;
+
+    // Fetch text
+    $out = array();
+    foreach ($newranges as $from => $to) {
+      $out[] = substr($text, $from, $to - $from);
+    }
+
+    // Let translators have the ... separator text as one chunk.
+    $dots = explode('!excerpt', t('... !excerpt ... !excerpt ...'));
+
+    $text = (isset($newranges[0]) ? '' : $dots[0]) . implode($dots[1], $out) . $dots[2];
+    $text = check_plain($text);
+
+    return $this->highlightField($text, $keys);
+  }
+
+  /**
+   * Marks occurrences of the search keywords in a text field.
+   *
+   * @param string $text
+   *   The text of the field.
+   * @param array $keys
+   *   Search keywords entered by the user.
+   *
+   * @return string
+   *   The field's text with all occurrences of search keywords highlighted.
+   */
+  protected function highlightField($text, array $keys) {
+    $replace = $this->options['prefix'] . '\0' . $this->options['suffix'];
+    $text = preg_replace('/' . self::$boundary . '(' . implode('|', $keys) . ')' . self::$boundary . '/iu', $replace, ' ' . $text);
+    return substr($text, 1);
+  }
+
+}

+ 4 - 1
includes/processor_ignore_case.inc

@@ -6,7 +6,10 @@
 class SearchApiIgnoreCase extends SearchApiAbstractProcessor {
 
   protected function process(&$value) {
-    $value = drupal_strtolower($value);
+    // We don't touch integers, NULL values or the like.
+    if (is_string($value)) {
+      $value = drupal_strtolower($value);
+    }
   }
 
 }

+ 17 - 3
includes/processor_stopwords.inc

@@ -5,6 +5,13 @@
  */
 class SearchApiStopWords extends SearchApiAbstractProcessor {
 
+  /**
+   * Holds all words ignored for the last query.
+   *
+   * @var array
+   */
+  protected $ignored = array();
+
   public function configurationForm() {
     $form = parent::configurationForm();
 
@@ -50,7 +57,7 @@ class SearchApiStopWords extends SearchApiAbstractProcessor {
 
   public function process(&$value) {
     $stopwords = $this->getStopWords();
-    if (empty($stopwords)) {
+    if (empty($stopwords) && !is_string($value)) {
       return;
     }
     $words = preg_split('/\s+/', $value);
@@ -63,12 +70,19 @@ class SearchApiStopWords extends SearchApiAbstractProcessor {
     $value = implode(' ', $words);
   }
 
+  public function preprocessSearchQuery(SearchApiQuery $query) {
+    $this->ignored = array();
+    parent::preprocessSearchQuery($query);
+  }
+
   public function postprocessSearchResults(array &$response, SearchApiQuery $query) {
-    if (isset($this->ignored)) {
+    if ($this->ignored) {
       if (isset($response['ignored'])) {
         $response['ignored'] = array_merge($response['ignored'], $this->ignored);
       }
-      else $response['ignored'] = $this->ignored;
+      else {
+        $response['ignored'] = $this->ignored;
+      }
     }
   }
 

+ 21 - 7
includes/processor_tokenizer.inc

@@ -18,6 +18,17 @@ class SearchApiTokenizer extends SearchApiAbstractProcessor {
 
   public function configurationForm() {
     $form = parent::configurationForm();
+
+    // Only make fulltext fields available as options.
+    $fields = $this->index->getFields();
+    $field_options = array();
+    foreach ($fields as $name => $field) {
+      if (empty($field['real_type']) && search_api_is_text_type($field['type'])) {
+        $field_options[$name] = $field['name'];
+      }
+    }
+    $form['fields']['#options'] = $field_options;
+
     $form += array(
       'spaces' => array(
         '#type' => 'textfield',
@@ -37,7 +48,7 @@ class SearchApiTokenizer extends SearchApiAbstractProcessor {
     );
 
     if (!empty($this->options)) {
-      $form['spaces']['#default_value']   = $this->options['spaces'];
+      $form['spaces']['#default_value'] = $this->options['spaces'];
       $form['ignorable']['#default_value'] = $this->options['ignorable'];
     }
 
@@ -76,12 +87,15 @@ class SearchApiTokenizer extends SearchApiAbstractProcessor {
   }
 
   protected function process(&$value) {
-    $this->prepare();
-    if ($this->ignorable) {
-      $value = preg_replace('/' . $this->ignorable . '+/u', '', $value);
-    }
-    if ($this->spaces) {
-      $value = preg_replace('/' . $this->spaces . '+/u', ' ', $value);
+    // We don't touch integers, NULL values or the like.
+    if (is_string($value)) {
+      $this->prepare();
+      if ($this->ignorable) {
+        $value = preg_replace('/' . $this->ignorable . '+/u', '', $value);
+      }
+      if ($this->spaces) {
+        $value = preg_replace('/' . $this->spaces . '+/u', ' ', $value);
+      }
     }
   }
 

+ 15 - 0
includes/processor_transliteration.inc

@@ -0,0 +1,15 @@
+<?php
+
+/**
+ * Processor for making searches insensitive to accents and other non-ASCII characters.
+ */
+class SearchApiTransliteration extends SearchApiAbstractProcessor {
+
+  protected function process(&$value) {
+    // We don't touch integers, NULL values or the like.
+    if (is_string($value)) {
+      $value = transliteration_get($value, '', language_default('language'));
+    }
+  }
+
+}

+ 177 - 314
includes/query.inc

@@ -9,7 +9,7 @@
 interface SearchApiQueryInterface {
 
   /**
-   * Constructor used when creating SearchApiQueryInterface objects.
+   * Constructs a new search query.
    *
    * @param SearchApiIndex $index
    *   The index the query should be executed on.
@@ -46,6 +46,8 @@ interface SearchApiQueryInterface {
   public function __construct(SearchApiIndex $index, array $options = array());
 
   /**
+   * Retrieves the parse modes supported by this query class.
+   *
    * @return array
    *   An associative array of parse modes recognized by objects of this class.
    *   The keys are the parse modes' ids, values are associative arrays
@@ -56,9 +58,9 @@ interface SearchApiQueryInterface {
   public function parseModes();
 
   /**
-   * Method for creating a filter to use with this query object.
+   * Creates a new filter to use with this query object.
    *
-   * @param $conjunction
+   * @param string $conjunction
    *   The conjunction to use for the filter - either 'AND' or 'OR'.
    *
    * @return SearchApiQueryFilterInterface
@@ -67,10 +69,12 @@ interface SearchApiQueryInterface {
   public function createFilter($conjunction = 'AND');
 
   /**
-   * Sets the keys to search for. If this method is not called on the query
-   * before execution, this will be a filter-only query.
+   * Sets the keys to search for.
+   *
+   * If this method is not called on the query before execution, this will be a
+   * filter-only query.
    *
-   * @param $keys
+   * @param array|string|null $keys
    *   A string with the unparsed search keys, or NULL to use no search keys.
    *
    * @return SearchApiQueryInterface
@@ -79,18 +83,20 @@ interface SearchApiQueryInterface {
   public function keys($keys = NULL);
 
   /**
-   * Sets the fields that will be searched for the search keys. If this is not
-   * called, all fulltext fields should be searched.
+   * Sets the fields that will be searched for the search keys.
+   *
+   * If this is not called, all fulltext fields will be searched.
    *
    * @param array $fields
    *   An array containing fulltext fields that should be searched.
    *
-   * @throws SearchApiException
-   *   If one of the fields isn't of type "text".
-   *
    * @return SearchApiQueryInterface
    *   The called object.
+   *
+   * @throws SearchApiException
+   *   If one of the fields isn't of type "text".
    */
+  // @todo Allow calling with NULL.
   public function fields(array $fields);
 
   /**
@@ -105,13 +111,13 @@ interface SearchApiQueryInterface {
   public function filter(SearchApiQueryFilterInterface $filter);
 
   /**
-   * Add a new ($field $operator $value) condition filter.
+   * Adds a new ($field $operator $value) condition filter.
    *
-   * @param $field
+   * @param string $field
    *   The field to filter on, e.g. 'title'.
-   * @param $value
+   * @param mixed $value
    *   The value the field should have (or be related to by the operator).
-   * @param $operator
+   * @param string $operator
    *   The operator to use for checking the constraint. The following operators
    *   are supported for primitive types: "=", "<>", "<", "<=", ">=", ">". They
    *   have the same semantics as the corresponding SQL operators.
@@ -127,31 +133,34 @@ interface SearchApiQueryInterface {
   public function condition($field, $value, $operator = '=');
 
   /**
-   * Add a sort directive to this search query. If no sort is manually set, the
-   * results will be sorted descending by relevance.
+   * Adds a sort directive to this search query.
+   *
+   * If no sort is manually set, the results will be sorted descending by
+   * relevance.
    *
-   * @param $field
+   * @param string $field
    *   The field to sort by. The special fields 'search_api_relevance' (sort by
    *   relevance) and 'search_api_id' (sort by item id) may be used.
-   * @param $order
+   * @param string $order
    *   The order to sort items in - either 'ASC' or 'DESC'.
    *
-   * @throws SearchApiException
-   *   If the field is multi-valued or of a fulltext type.
-   *
    * @return SearchApiQueryInterface
    *   The called object.
+   *
+   * @throws SearchApiException
+   *   If the field is multi-valued or of a fulltext type.
    */
   public function sort($field, $order = 'ASC');
 
   /**
-   * Adds a range of results to return. This will be saved in the query's
-   * options. If called without parameters, this will remove all range
-   * restrictions previously set.
+   * Adds a range of results to return.
    *
-   * @param $offset
+   * This will be saved in the query's options. If called without parameters,
+   * this will remove all range restrictions previously set.
+   *
+   * @param int|null $offset
    *   The zero-based offset of the first result returned.
-   * @param $limit
+   * @param int|null $limit
    *   The number of results to return.
    *
    * @return SearchApiQueryInterface
@@ -206,24 +215,29 @@ interface SearchApiQueryInterface {
   public function preExecute();
 
   /**
-   * Postprocess the search results before they are returned.
+   * Postprocesses the search results before they are returned.
    *
    * This method should always be called by execute() and contain all necessary
    * operations after the results are returned from the server.
    *
    * @param array $results
-   *   The results returned by the server, which may be altered.
+   *   The results returned by the server, which may be altered. The data
+   *   structure is the same as returned by execute().
    */
   public function postExecute(array &$results);
 
   /**
+   * Retrieves the index associated with this search.
+   *
    * @return SearchApiIndex
    *   The search index this query should be executed on.
    */
   public function getIndex();
 
   /**
-   * @return
+   * Retrieves the search keys for this query.
+   *
+   * @return array|string|null
    *   This object's search keys - either a string or an array specifying a
    *   complex search expression.
    *   An array will contain a '#conjunction' key specifying the conjunction
@@ -236,41 +250,59 @@ interface SearchApiQueryInterface {
    *   the terms in the array should be excluded; with the "OR" conjunction and
    *   negation, all results containing one or more of the terms in the array
    *   should be excluded.
+   *
+   * @see keys()
    */
   public function &getKeys();
 
   /**
-   * @return
+   * Retrieves the unparsed search keys for this query as originally entered.
+   *
+   * @return array|string|null
    *   The unprocessed search keys, exactly as passed to this object. Has the
-   *   same format as getKeys().
+   *   same format as the return value of getKeys().
+   *
+   * @see keys()
    */
   public function getOriginalKeys();
 
   /**
+   * Retrieves the fulltext fields that will be searched for the search keys.
+   *
    * @return array
    *   An array containing the fields that should be searched for the search
    *   keys.
+   *
+   * @see fields()
    */
   public function &getFields();
 
   /**
+   * Retrieves the filter object associated with this search query.
+   *
    * @return SearchApiQueryFilterInterface
    *   This object's associated filter object.
    */
   public function getFilter();
 
   /**
+   * Retrieves the sorts set for this query.
+   *
    * @return array
    *   An array specifying the sort order for this query. Array keys are the
    *   field names in order of importance, the values are the respective order
    *   in which to sort the results according to the field.
+   *
+   * @see sort()
    */
   public function &getSort();
 
   /**
-   * @param $name string
+   * Retrieves an option set on this search query.
+   *
+   * @param string $name
    *   The name of an option.
-   * @param $default mixed
+   * @param mixed $default
    *   The value to return if the specified option is not set.
    *
    * @return mixed
@@ -279,6 +311,8 @@ interface SearchApiQueryInterface {
   public function getOption($name, $default = NULL);
 
   /**
+   * Sets an option for this search query.
+   *
    * @param string $name
    *   The name of an option.
    * @param mixed $value
@@ -289,6 +323,11 @@ interface SearchApiQueryInterface {
   public function setOption($name, $value);
 
   /**
+   * Retrieves all options set for this search query.
+   *
+   * The return value is a reference to the options so they can also be altered
+   * this way.
+   *
    * @return array
    *   An associative array of query options.
    */
@@ -297,7 +336,7 @@ interface SearchApiQueryInterface {
 }
 
 /**
- * Standard implementation of SearchApiQueryInterface.
+ * Provides a standard implementation of the SearchApiQueryInterface.
  */
 class SearchApiQuery implements SearchApiQueryInterface {
 
@@ -351,44 +390,14 @@ class SearchApiQuery implements SearchApiQueryInterface {
   protected $options;
 
   /**
-   * Count for providing a unique ID.
+   * Flag for whether preExecute() was already called for this query.
+   *
+   * @var bool
    */
-  protected static $count = 0;
+  protected $pre_execute = FALSE;
 
   /**
-   * Constructor for SearchApiQuery objects.
-   *
-   * @param SearchApiIndex $index
-   *   The index the query should be executed on.
-   * @param array $options
-   *   Associative array of options configuring this query. Recognized options
-   *   are:
-   *   - conjunction: The type of conjunction to use for this query - either
-   *     'AND' or 'OR'. 'AND' by default. This only influences the search keys,
-   *     filters will always use AND by default.
-   *   - 'parse mode': The mode with which to parse the $keys variable, if it
-   *     is set and not already an array. See SearchApiQuery::parseModes() for
-   *     recognized parse modes.
-   *   - languages: The languages to search for, as an array of language IDs.
-   *     If not specified, all languages will be searched. Language-neutral
-   *     content (LANGUAGE_NONE) is always searched.
-   *   - offset: The position of the first returned search results relative to
-   *     the whole result in the index.
-   *   - limit: The maximum number of search results to return. -1 means no
-   *     limit.
-   *   - 'filter class': Can be used to change the SearchApiQueryFilterInterface
-   *     implementation to use.
-   *   - 'search id': A string that will be used as the identifier when storing
-   *     this search in the Search API's static cache.
-   *   - search_api_access_account: The account which will be used for entity
-   *     access checks, if available and enabled for the index.
-   *   - search_api_bypass_access: If set to TRUE, entity access checks will be
-   *     skipped, even if enabled for the index.
-   *   All options are optional. Third-party modules might define and use other
-   *   options not listed here.
-   *
-   * @throws SearchApiException
-   *   If a search on that index (or with those options) won't be possible.
+   * {@inheritdoc}
    */
   public function __construct(SearchApiIndex $index, array $options = array()) {
     if (empty($index->options['fields'])) {
@@ -415,12 +424,7 @@ class SearchApiQuery implements SearchApiQueryInterface {
   }
 
   /**
-   * @return array
-   *   An associative array of parse modes recognized by objects of this class.
-   *   The keys are the parse modes' ids, values are associative arrays
-   *   containing the following entries:
-   *   - name: The translated name of the parse mode.
-   *   - description: (optional) A translated text describing the parse mode.
+   * {@inheritdoc}
    */
   public function parseModes() {
     $modes['direct'] = array(
@@ -442,10 +446,7 @@ class SearchApiQuery implements SearchApiQueryInterface {
   }
 
   /**
-   * Parses the keys string according to the $mode parameter.
-   *
-   * @return
-   *   The parsed keys. Either a string or an array.
+   * {@inheritdoc}
    */
   protected function parseKeys($keys, $mode) {
     if ($keys === NULL || is_array($keys)) {
@@ -460,7 +461,7 @@ class SearchApiQuery implements SearchApiQueryInterface {
         return array('#conjunction' => $this->options['conjunction'], $keys);
 
       case 'terms':
-        $ret = explode(' ', $keys);
+        $ret = preg_split('/\s+/u', $keys);
         $quoted = FALSE;
         $str = '';
         foreach ($ret as $k => $v) {
@@ -500,13 +501,7 @@ class SearchApiQuery implements SearchApiQueryInterface {
   }
 
   /**
-   * Method for creating a filter to use with this query object.
-   *
-   * @param $conjunction
-   *   The conjunction to use for the filter - either 'AND' or 'OR'.
-   *
-   * @return SearchApiQueryFilterInterface
-   *   A filter object that is set to use the specified conjunction.
+   * {@inheritdoc}
    */
   public function createFilter($conjunction = 'AND') {
     $filter_class = $this->options['filter class'];
@@ -514,14 +509,7 @@ class SearchApiQuery implements SearchApiQueryInterface {
   }
 
   /**
-   * Sets the keys to search for. If this method is not called on the query
-   * before execution, this will be a filter-only query.
-   *
-   * @param $keys
-   *   A string with the unparsed search keys, or NULL to use no search keys.
-   *
-   * @return SearchApiQuery
-   *   The called object.
+   * {@inheritdoc}
    */
   public function keys($keys = NULL) {
     $this->orig_keys = $keys;
@@ -534,17 +522,7 @@ class SearchApiQuery implements SearchApiQueryInterface {
     return $this;
   }
   /**
-   * Sets the fields that will be searched for the search keys. If this is not
-   * called, all fulltext fields should be searched.
-   *
-   * @param array $fields
-   *   An array containing fulltext fields that should be searched.
-   *
-   * @throws SearchApiException
-   *   If one of the fields isn't of type "text".
-   *
-   * @return SearchApiQueryInterface
-   *   The called object.
+   * {@inheritdoc}
    */
   public function fields(array $fields) {
     $fulltext_fields = $this->index->getFulltextFields();
@@ -556,13 +534,7 @@ class SearchApiQuery implements SearchApiQueryInterface {
   }
 
   /**
-   * Adds a subfilter to this query's filter.
-   *
-   * @param SearchApiQueryFilterInterface $filter
-   *   A SearchApiQueryFilter object that should be added as a subfilter.
-   *
-   * @return SearchApiQuery
-   *   The called object.
+   * {@inheritdoc}
    */
   public function filter(SearchApiQueryFilterInterface $filter) {
     $this->filter->filter($filter);
@@ -570,24 +542,7 @@ class SearchApiQuery implements SearchApiQueryInterface {
   }
 
   /**
-   * Add a new ($field $operator $value) condition filter.
-   *
-   * @param $field
-   *   The field to filter on, e.g. 'title'.
-   * @param $value
-   *   The value the field should have (or be related to by the operator).
-   * @param $operator
-   *   The operator to use for checking the constraint. The following operators
-   *   are supported for primitive types: "=", "<>", "<", "<=", ">=", ">". They
-   *   have the same semantics as the corresponding SQL operators.
-   *   If $field is a fulltext field, $operator can only be "=" or "<>", which
-   *   are in this case interpreted as "contains" or "doesn't contain",
-   *   respectively.
-   *   If $value is NULL, $operator also can only be "=" or "<>", meaning the
-   *   field must have no or some value, respectively.
-   *
-   * @return SearchApiQuery
-   *   The called object.
+   * {@inheritdoc}
    */
   public function condition($field, $value, $operator = '=') {
     $this->filter->condition($field, $value, $operator);
@@ -595,20 +550,7 @@ class SearchApiQuery implements SearchApiQueryInterface {
   }
 
   /**
-   * Add a sort directive to this search query. If no sort is manually set, the
-   * results will be sorted descending by relevance.
-   *
-   * @param $field
-   *   The field to sort by. The special fields 'search_api_relevance' (sort by
-   *   relevance) and 'search_api_id' (sort by item id) may be used.
-   * @param $order
-   *   The order to sort items in - either 'ASC' or 'DESC'.
-   *
-   * @throws SearchApiException
-   *   If the field is multi-valued or of a fulltext type.
-   *
-   * @return SearchApiQuery
-   *   The called object.
+   * {@inheritdoc}
    */
   public function sort($field, $order = 'ASC') {
     $fields = $this->index->options['fields'];
@@ -629,17 +571,7 @@ class SearchApiQuery implements SearchApiQueryInterface {
   }
 
   /**
-   * Adds a range of results to return. This will be saved in the query's
-   * options. If called without parameters, this will remove all range
-   * restrictions previously set.
-   *
-   * @param $offset
-   *   The zero-based offset of the first result returned.
-   * @param $limit
-   *   The number of results to return.
-   *
-   * @return SearchApiQueryInterface
-   *   The called object.
+   * {@inheritdoc}
    */
   public function range($offset = NULL, $limit = NULL) {
     $this->options['offset'] = $offset;
@@ -649,42 +581,9 @@ class SearchApiQuery implements SearchApiQueryInterface {
 
 
   /**
-   * Executes this search query.
-   *
-   * @return array
-   *   An associative array containing the search results. The following keys
-   *   are standardized:
-   *   - 'result count': The overall number of results for this query, without
-   *     range restrictions. Might be approximated, for large numbers.
-   *   - results: An array of results, ordered as specified. The array keys are
-   *     the items' IDs, values are arrays containing the following keys:
-   *     - id: The item's ID.
-   *     - score: A float measuring how well the item fits the search.
-   *     - fields: (optional) If set, an array containing some field values
-   *       already ready-to-use. This allows search engines (or postprocessors)
-   *       to store extracted fields so other modules don't have to extract them
-   *       again. This fields should always be checked by modules that want to
-   *       use field contents of the result items.
-   *     - entity: (optional) If set, the fully loaded result item. This field
-   *       should always be used by modules using search results, to avoid
-   *       duplicate item loads.
-   *     - excerpt: (optional) If set, an HTML text containing highlighted
-   *       portions of the fulltext that match the query.
-   *   - warnings: A numeric array of translated warning messages that may be
-   *     displayed to the user.
-   *   - ignored: A numeric array of search keys that were ignored for this
-   *     search (e.g., because of being too short or stop words).
-   *   - performance: An associative array with the time taken (as floats, in
-   *     seconds) for specific parts of the search execution:
-   *     - complete: The complete runtime of the query.
-   *     - hooks: Hook invocations and other client-side preprocessing.
-   *     - preprocessing: Preprocessing of the service class.
-   *     - execution: The actual query to the search server, in whatever form.
-   *     - postprocessing: Preparing the results for returning.
-   *   Additional metadata may be returned in other keys. Only 'result count'
-   *   and 'result' always have to be set, all other entries are optional.
+   * {@inheritdoc}
    */
-  public final function execute() {
+  public function execute() {
     $start = microtime(TRUE);
 
     // Prepare the query for execution by the server.
@@ -711,7 +610,12 @@ class SearchApiQuery implements SearchApiQueryInterface {
   }
 
   /**
-   * Helper method for adding a language filter.
+   * Adds language filters for the query.
+   *
+   * Internal helper function.
+   *
+   * @param array $languages
+   *   The languages for which results should be returned.
    */
   protected function addLanguages(array $languages) {
     if (array_search(LANGUAGE_NONE, $languages) === FALSE) {
@@ -755,37 +659,32 @@ class SearchApiQuery implements SearchApiQueryInterface {
   }
 
   /**
-   * Prepares the query object for the search.
-   *
-   * This method should always be called by execute() and contain all necessary
-   * operations before the query is passed to the server's search() method.
+   * {@inheritdoc}
    */
   public function preExecute() {
-    // Add filter for languages.
-    if (isset($this->options['languages'])) {
-      $this->addLanguages($this->options['languages']);
-    }
+    // Make sure to only execute this once per query.
+    if (!$this->pre_execute) {
+      $this->pre_execute = TRUE;
+      // Add filter for languages.
+      if (isset($this->options['languages'])) {
+        $this->addLanguages($this->options['languages']);
+      }
 
-    // Add fulltext fields, unless set
-    if ($this->fields === NULL) {
-      $this->fields = $this->index->getFulltextFields();
-    }
+      // Add fulltext fields, unless set
+      if ($this->fields === NULL) {
+        $this->fields = $this->index->getFulltextFields();
+      }
 
-    // Preprocess query.
-    $this->index->preprocessSearchQuery($this);
+      // Preprocess query.
+      $this->index->preprocessSearchQuery($this);
 
-    // Let modules alter the query.
-    drupal_alter('search_api_query', $this);
+      // Let modules alter the query.
+      drupal_alter('search_api_query', $this);
+    }
   }
 
   /**
-   * Postprocess the search results before they are returned.
-   *
-   * This method should always be called by execute() and contain all necessary
-   * operations after the results are returned from the server.
-   *
-   * @param array $results
-   *   The results returned by the server, which may be altered.
+   * {@inheritdoc}
    */
   public function postExecute(array &$results) {
     // Postprocess results.
@@ -793,86 +692,56 @@ class SearchApiQuery implements SearchApiQueryInterface {
   }
 
   /**
-   * @return SearchApiIndex
-   *   The search index this query will be executed on.
+   * {@inheritdoc}
    */
   public function getIndex() {
     return $this->index;
   }
 
   /**
-   * @return
-   *   This object's search keys - either a string or an array specifying a
-   *   complex search expression.
-   *   An array will contain a '#conjunction' key specifying the conjunction
-   *   type, and search strings or nested expression arrays at numeric keys.
-   *   Additionally, a '#negation' key might be present, which means – unless it
-   *   maps to a FALSE value – that the search keys contained in that array
-   *   should be negated, i.e. not be present in returned results. The negation
-   *   works on the whole array, not on each contained term individually – i.e.,
-   *   with the "AND" conjunction and negation, only results that contain all
-   *   the terms in the array should be excluded; with the "OR" conjunction and
-   *   negation, all results containing one or more of the terms in the array
-   *   should be excluded.
+   * {@inheritdoc}
    */
   public function &getKeys() {
     return $this->keys;
   }
 
   /**
-   * @return
-   *   The unprocessed search keys, exactly as passed to this object. Has the
-   *   same format as getKeys().
+   * {@inheritdoc}
    */
   public function getOriginalKeys() {
     return $this->orig_keys;
   }
 
   /**
-   * @return array
-   *   An array containing the fields that should be searched for the search
-   *   keys.
+   * {@inheritdoc}
    */
   public function &getFields() {
     return $this->fields;
   }
 
   /**
-   * @return SearchApiQueryFilterInterface
-   *   This object's associated filter object.
+   * {@inheritdoc}
    */
   public function getFilter() {
     return $this->filter;
   }
 
   /**
-   * @return array
-   *   An array specifying the sort order for this query. Array keys are the
-   *   field names in order of importance, the values are the respective order
-   *   in which to sort the results according to the field.
+   * {@inheritdoc}
    */
   public function &getSort() {
     return $this->sort;
   }
 
   /**
-   * @param $name string
-   *   The name of an option.
-   *
-   * @return mixed
-   *   The option with the specified name, if set, or NULL otherwise.
+   * {@inheritdoc}
    */
   public function getOption($name, $default = NULL) {
     return array_key_exists($name, $this->options) ? $this->options[$name] : $default;
   }
 
   /**
-   * @param string $name
-   *   The name of an option.
-   * @param mixed $value
-   *   The new value of the option.
-   *
-   * @return The option's previous value.
+   * {@inheritdoc}
    */
   public function setOption($name, $value) {
     $old = $this->getOption($name);
@@ -881,28 +750,46 @@ class SearchApiQuery implements SearchApiQueryInterface {
   }
 
   /**
-   * @return array
-   *   An associative array of query options.
+   * {@inheritdoc}
    */
   public function &getOptions() {
     return $this->options;
   }
 
+  /**
+   * Implements the magic __sleep() method to avoid serializing the index.
+   */
+  public function __sleep() {
+    $this->index_id = $this->index->machine_name;
+    $keys = get_object_vars($this);
+    unset($keys['index']);
+    return array_keys($keys);
+  }
+
+  /**
+   * Implements the magic __wakeup() method to reload the query's index.
+   */
+  public function __wakeup() {
+    if (!isset($this->index) && !empty($this->index_id)) {
+      $this->index = search_api_index_load($this->index_id);
+      unset($this->index_id);
+    }
+  }
+
 }
 
 /**
- * Interface representing a search query filter, that filters on one or more
- * fields with a specific conjunction (AND or OR).
+ * Represents a filter on a search query.
  *
- * Methods not noting otherwise will return the object itself, so calls can be
- * chained.
+ * Filters apply conditions on one or more fields with a specific conjunction
+ * (AND or OR) and may contain nested filters.
  */
 interface SearchApiQueryFilterInterface {
 
   /**
    * Constructs a new filter that uses the specified conjunction.
    *
-   * @param $conjunction
+   * @param string $conjunction
    *   The conjunction to use for this filter - either 'AND' or 'OR'.
    */
   public function __construct($conjunction = 'AND');
@@ -910,7 +797,7 @@ interface SearchApiQueryFilterInterface {
   /**
    * Sets this filter's conjunction.
    *
-   * @param $conjunction
+   * @param string $conjunction
    *   The conjunction to use for this filter - either 'AND' or 'OR'.
    *
    * @return SearchApiQueryFilterInterface
@@ -921,7 +808,7 @@ interface SearchApiQueryFilterInterface {
   /**
    * Adds a subfilter.
    *
-   * @param $filter
+   * @param SearchApiQueryFilterInterface $filter
    *   A SearchApiQueryFilterInterface object that should be added as a
    *   subfilter.
    *
@@ -931,13 +818,13 @@ interface SearchApiQueryFilterInterface {
   public function filter(SearchApiQueryFilterInterface $filter);
 
   /**
-   * Add a new ($field $operator $value) condition.
+   * Adds a new ($field $operator $value) condition.
    *
-   * @param $field
+   * @param string $field
    *   The field to filter on, e.g. 'title'.
-   * @param $value
+   * @param mixed $value
    *   The value the field should have (or be related to by the operator).
-   * @param $operator
+   * @param string $operator
    *   The operator to use for checking the constraint. The following operators
    *   are supported for primitive types: "=", "<>", "<", "<=", ">=", ">". They
    *   have the same semantics as the corresponding SQL operators.
@@ -953,12 +840,16 @@ interface SearchApiQueryFilterInterface {
   public function condition($field, $value, $operator = '=');
 
   /**
-   * @return
+   * Retrieves the conjunction used by this filter.
+   *
+   * @return string
    *   The conjunction used by this filter - either 'AND' or 'OR'.
    */
   public function getConjunction();
 
   /**
+   * Return all conditions and nested filters contained in this filter.
+   *
    * @return array
    *   An array containing this filter's subfilters. Each of these is either an
    *   array (field, value, operator), or another SearchApiFilter object.
@@ -968,24 +859,29 @@ interface SearchApiQueryFilterInterface {
 }
 
 /**
- * Standard implementation of SearchApiQueryFilterInterface.
+ * Provides a standard implementation of SearchApiQueryFilterInterface.
  */
 class SearchApiQueryFilter implements SearchApiQueryFilterInterface {
 
   /**
-   * Array containing subfilters. Each of these is either an array
-   * (field, value, operator), or another SearchApiFilter object.
+   * Array containing subfilters.
+   *
+   * Each of these is either an array (field, value, operator), or another
+   * SearchApiFilter object.
+   *
+   * @var array
    */
   protected $filters;
 
-  /** String specifying this filter's conjunction ('AND' or 'OR'). */
+  /**
+   * String specifying this filter's conjunction ('AND' or 'OR').
+   *
+   * @var string
+   */
   protected $conjunction;
 
   /**
-   * Constructs a new filter that uses the specified conjunction.
-   *
-   * @param $conjunction
-   *   The conjunction to use for this filter - either 'AND' or 'OR'.
+   * {@inheritdoc}
    */
   public function __construct($conjunction = 'AND') {
     $this->setConjunction($conjunction);
@@ -993,13 +889,7 @@ class SearchApiQueryFilter implements SearchApiQueryFilterInterface {
   }
 
   /**
-   * Sets this filter's conjunction.
-   *
-   * @param $conjunction
-   *   The conjunction to use for this filter - either 'AND' or 'OR'.
-   *
-   * @return SearchApiQueryFilter
-   *   The called object.
+   * {@inheritdoc}
    */
   public function setConjunction($conjunction) {
     $this->conjunction = strtoupper(trim($conjunction)) == 'OR' ? 'OR' : 'AND';
@@ -1007,14 +897,7 @@ class SearchApiQueryFilter implements SearchApiQueryFilterInterface {
   }
 
   /**
-   * Adds a subfilter.
-   *
-   * @param $filter
-   *   A SearchApiQueryFilterInterface object that should be added as a
-   *   subfilter.
-   *
-   * @return SearchApiQueryFilter
-   *   The called object.
+   * {@inheritdoc}
    */
   public function filter(SearchApiQueryFilterInterface $filter) {
     $this->filters[] = $filter;
@@ -1022,24 +905,7 @@ class SearchApiQueryFilter implements SearchApiQueryFilterInterface {
   }
 
   /**
-   * Add a new ($field $operator $value) condition.
-   *
-   * @param $field
-   *   The field to filter on, e.g. 'title'.
-   * @param $value
-   *   The value the field should have (or be related to by the operator).
-   * @param $operator
-   *   The operator to use for checking the constraint. The following operators
-   *   are supported for primitive types: "=", "<>", "<", "<=", ">=", ">". They
-   *   have the same semantics as the corresponding SQL operators.
-   *   If $field is a fulltext field, $operator can only be "=" or "<>", which
-   *   are in this case interpreted as "contains" or "doesn't contain",
-   *   respectively.
-   *   If $value is NULL, $operator also can only be "=" or "<>", meaning the
-   *   field must have no or some value, respectively.
-   *
-   * @return SearchApiQueryFilter
-   *   The called object.
+   * {@inheritdoc}
    */
   public function condition($field, $value, $operator = '=') {
     $this->filters[] = array($field, $value, $operator);
@@ -1047,17 +913,14 @@ class SearchApiQueryFilter implements SearchApiQueryFilterInterface {
   }
 
   /**
-   * @return
-   *   The conjunction used by this filter - either 'AND' or 'OR'.
+   * {@inheritdoc}
    */
   public function getConjunction() {
     return $this->conjunction;
   }
 
   /**
-   * @return array
-   *   An array containing this filter's subfilters. Each of these is either an
-   *   array (field, value, operator), or another SearchApiFilter object.
+   * {@inheritdoc}
    */
   public function &getFilters() {
     return $this->filters;

+ 38 - 102
includes/service.inc

@@ -9,8 +9,9 @@
 interface SearchApiServiceInterface {
 
   /**
-   * Constructor for a service class, setting the server configuration used with
-   * this service.
+   * Constructs a service object.
+   *
+   * This will set the server configuration used with this service.
    *
    * @param SearchApiServer $server
    *   The server object for this service.
@@ -57,9 +58,10 @@ interface SearchApiServiceInterface {
   public function configurationFormSubmit(array $form, array &$values, array &$form_state);
 
   /**
-   * Determines whether this service class implementation supports a given
-   * feature. Features are optional extensions to Search API functionality and
-   * usually defined and used by third-party modules.
+   * Determines whether this service class supports a given feature.
+   *
+   * Features are optional extensions to Search API functionality and usually
+   * defined and used by third-party modules.
    *
    * There are currently three features defined directly in the Search API
    * project:
@@ -72,7 +74,7 @@ interface SearchApiServiceInterface {
    * @param string $feature
    *   The name of the optional feature.
    *
-   * @return boolean
+   * @return bool
    *   TRUE if this service knows and supports the specified feature. FALSE
    *   otherwise.
    */
@@ -121,15 +123,15 @@ interface SearchApiServiceInterface {
   public function addIndex(SearchApiIndex $index);
 
   /**
-   * Notify the server that the indexed field settings for the index have
-   * changed.
+   * Notify the server that the field settings for the index have changed.
+   *
    * If any user action is necessary as a result of this, the method should
    * use drupal_set_message() to notify the user.
    *
    * @param SearchApiIndex $index
    *   The updated index.
    *
-   * @return
+   * @return bool
    *   TRUE, if this change affected the server in any way that forces it to
    *   re-index the content. FALSE otherwise.
    */
@@ -144,6 +146,9 @@ interface SearchApiServiceInterface {
    *
    * If the index wasn't added to the server, the method call should be ignored.
    *
+   * Implementations of this method should also check whether $index->read_only
+   * is set, and don't delete any indexed data if it is.
+   *
    * @param $index
    *   Either an object representing the index to remove, or its machine name
    *   (if the index was completely deleted).
@@ -234,6 +239,10 @@ interface SearchApiServiceInterface {
 
 /**
  * Abstract class with generic implementation of most service methods.
+ *
+ * For creating your own service class extending this class, you only need to
+ * implement indexItems(), deleteItems() and search() from the
+ * SearchApiServiceInterface interface.
  */
 abstract class SearchApiAbstractService implements SearchApiServiceInterface {
 
@@ -250,13 +259,9 @@ abstract class SearchApiAbstractService implements SearchApiServiceInterface {
   protected $options = array();
 
   /**
-   * Constructor for a service class, setting the server configuration used with
-   * this service.
+   * Implements SearchApiServiceInterface::__construct().
    *
    * The default implementation sets $this->server and $this->options.
-   *
-   * @param SearchApiServer $server
-   *   The server object for this service.
    */
   public function __construct(SearchApiServer $server) {
     $this->server = $server;
@@ -264,46 +269,28 @@ abstract class SearchApiAbstractService implements SearchApiServiceInterface {
   }
 
   /**
-   * Form callback. Might be called on an uninitialized object - in this case,
-   * the form is for configuring a newly created server.
+   * Implements SearchApiServiceInterface::__construct().
    *
    * Returns an empty form by default.
-   *
-   * @return array
-   *   A form array for setting service-specific options.
    */
   public function configurationForm(array $form, array &$form_state) {
     return array();
   }
 
   /**
-   * Validation callback for the form returned by configurationForm().
+   * Implements SearchApiServiceInterface::__construct().
    *
    * Does nothing by default.
-   *
-   * @param array $form
-   *   The form returned by configurationForm().
-   * @param array $values
-   *   The part of the $form_state['values'] array corresponding to this form.
-   * @param array $form_state
-   *   The complete form state.
    */
   public function configurationFormValidate(array $form, array &$values, array &$form_state) {
     return;
   }
 
   /**
-   * Submit callback for the form returned by configurationForm().
+   * Implements SearchApiServiceInterface::__construct().
    *
    * The default implementation just ensures that additional elements in
    * $options, not present in the form, don't get lost at the update.
-   *
-   * @param array $form
-   *   The form returned by configurationForm().
-   * @param array $values
-   *   The part of the $form_state['values'] array corresponding to this form.
-   * @param array $form_state
-   *   The complete form state.
    */
   public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
     if (!empty($this->options)) {
@@ -313,32 +300,16 @@ abstract class SearchApiAbstractService implements SearchApiServiceInterface {
   }
 
   /**
-   * Determines whether this service class implementation supports a given
-   * feature. Features are optional extensions to Search API functionality and
-   * usually defined and used by third-party modules.
+   * Implements SearchApiServiceInterface::__construct().
    *
-   * There are currently three features defined directly in the Search API
-   * project:
-   * - "search_api_facets", by the search_api_facetapi module.
-   * - "search_api_facets_operator_or", also by the search_api_facetapi module.
-   * - "search_api_mlt", by the search_api_views module.
-   * Other contrib modules might define additional features. These should always
-   * be properly documented in the module by which they are defined.
-   *
-   * @param string $feature
-   *   The name of the optional feature.
-   *
-   * @return boolean
-   *   TRUE if this service knows and supports the specified feature. FALSE
-   *   otherwise.
+   * The default implementation always returns FALSE.
    */
   public function supportsFeature($feature) {
     return FALSE;
   }
 
   /**
-   * View this server's settings. Output can be HTML or a render array, a <dl>
-   * listing all relevant settings is preferred.
+   * Implements SearchApiServiceInterface::__construct().
    *
    * The default implementation does a crude output as a definition list, with
    * option names taken from the configuration form.
@@ -364,8 +335,7 @@ abstract class SearchApiAbstractService implements SearchApiServiceInterface {
   }
 
   /**
-   * Called once, when the server is first created. Allows it to set up its
-   * necessary infrastructure.
+   * Implements SearchApiServiceInterface::__construct().
    *
    * Does nothing, by default.
    */
@@ -374,23 +344,16 @@ abstract class SearchApiAbstractService implements SearchApiServiceInterface {
   }
 
   /**
-   * Notifies this server that its fields are about to be updated. The server's
-   * $original property can be used to inspect the old property values.
+   * Implements SearchApiServiceInterface::__construct().
    *
-   * @return
-   *   TRUE, if the update requires reindexing of all content on the server.
+   * The default implementation always returns FALSE.
    */
   public function postUpdate() {
     return FALSE;
   }
 
   /**
-   * Notifies this server that it is about to be deleted from the database and
-   * should therefore clean up, if appropriate.
-   *
-   * Note that you shouldn't call the server's save() method, or any
-   * methods that might do that, from inside of this method as the server isn't
-   * present in the database anymore at this point.
+   * Implements SearchApiServiceInterface::__construct().
    *
    * By default, deletes all indexes from this server.
    */
@@ -402,65 +365,38 @@ abstract class SearchApiAbstractService implements SearchApiServiceInterface {
   }
 
   /**
-   * Add a new index to this server.
+   * Implements SearchApiServiceInterface::__construct().
    *
    * Does nothing, by default.
-   *
-   * @param SearchApiIndex $index
-   *   The index to add.
    */
   public function addIndex(SearchApiIndex $index) {
     return;
   }
 
   /**
-   * Notify the server that the indexed field settings for the index have
-   * changed.
-   * If any user action is necessary as a result of this, the method should
-   * use drupal_set_message() to notify the user.
+   * Implements SearchApiServiceInterface::__construct().
    *
-   * @param SearchApiIndex $index
-   *   The updated index.
-   *
-   * @return
-   *   TRUE, if this change affected the server in any way that forces it to
-   *   re-index the content. FALSE otherwise.
+   * The default implementation always returns FALSE.
    */
   public function fieldsUpdated(SearchApiIndex $index) {
     return FALSE;
   }
 
   /**
-   * Remove an index from this server.
-   *
-   * This might mean that the index has been deleted, or reassigned to a
-   * different server. If you need to distinguish between these cases, inspect
-   * $index->server.
+   * Implements SearchApiServiceInterface::__construct().
    *
    * By default, removes all items from that index.
-   *
-   * @param $index
-   *   Either an object representing the index to remove, or its machine name
-   *   (if the index was completely deleted).
    */
   public function removeIndex($index) {
-    $this->deleteItems('all', $index);
+    if (is_object($index) && empty($index->read_only)) {
+      $this->deleteItems('all', $index);
+    }
   }
 
   /**
-   * Create a query object for searching on an index lying on this server.
+   * Implements SearchApiServiceInterface::__construct().
    *
-   * @param SearchApiIndex $index
-   *   The index to search on.
-   * @param $options
-   *   Associative array of options configuring this query. See
-   *   SearchApiQueryInterface::__construct().
-   *
-   * @return SearchApiQueryInterface
-   *   An object for searching the given index.
-   *
-   * @throws SearchApiException
-   *   If the server is currently disabled.
+   * The default implementation returns a SearchApiQuery object.
    */
   public function query(SearchApiIndex $index, $options = array()) {
     return new SearchApiQuery($index, $options);

+ 20 - 6
search_api.admin.inc

@@ -953,7 +953,10 @@ function search_api_admin_index_status_form_submit(array $form, array &$form_sta
   $pre = 'admin/config/search/search_api/index/' . $index->machine_name;
   switch ($values['op']) {
     case t('Enable'):
-      $redirect = $pre . '/enable';
+      $redirect = array(
+        $pre . '/enable',
+        array('query' => array('token' => drupal_get_token($index->machine_name))),
+      );
       break;
     case t('Disable'):
       $redirect = $pre . '/disable';
@@ -1480,15 +1483,15 @@ function search_api_admin_index_fields(array $form, array &$form_state, SearchAp
   $fulltext_type = array(0 => 'text');
   $entity_types = entity_get_info();
   $default_types = search_api_default_field_types();
-  // $boosts = drupal_map_assoc(array('0.1', '0.2', '0.3', '0.5', '0.8', '1.0', '2.0', '3.0', '5.0', '8.0', '13.0', '21.0', '5000', '10000', '20000', '40000', '80000', '160000', '320000'));
-  $boosts = drupal_map_assoc(array('0.1', '0.2', '0.3', '0.5', '0.8', '1.0', '2.0', '3.0', '5.0', '8.0', '13.0', '21.0', '100', '1000', '1010', '1020', '1030', '1040', '1050', '1060'));
+  $boosts = drupal_map_assoc(array('0.1', '0.2', '0.3', '0.5', '0.8', '1.0', '2.0', '3.0', '5.0', '8.0', '13.0', '21.0'));
+
   $form_state['index'] = $index;
   $form['#theme'] = 'search_api_admin_fields_table';
   $form['#tree'] = TRUE;
   $form['description'] = array(
     '#type' => 'item',
     '#title' => t('Select fields to index'),
-    '#description' => t('<p>The datatype of a field determines how it can be used for searching and filtering. ' .
+    '#description' => t('<p>The datatype of a field determines how it can be used for searching and filtering. Fields indexed with type "Fulltext" and multi-valued fields (marked with <sup>1</sup>) cannot be used for sorting. ' .
         'The boost is used to give additional weight to certain fields, e.g. titles or tags. It only takes effect for fulltext fields.</p>' .
         '<p>Whether detailed field types are supported depends on the type of server this index resides on. ' .
         'In any case, fields of type "Fulltext" will always be fulltext-searchable.</p>'),
@@ -1499,6 +1502,11 @@ function search_api_admin_index_fields(array $form, array &$form_state, SearchAp
   }
   foreach ($fields as $key => $info) {
     $form['fields'][$key]['title']['#markup'] = check_plain($info['name']);
+    if (search_api_is_list_type($info['type'])) {
+      $form['fields'][$key]['title']['#markup'] .= ' <sup><a href="#note-multi-valued" class="note-ref">1</a></sup>';
+      $multi_valued_field_present = TRUE;
+    }
+    $form['fields'][$key]['machine_name']['#markup'] = check_plain($key);
     if (isset($info['description'])) {
       $form['fields'][$key]['description'] = array(
         '#type' => 'value',
@@ -1587,6 +1595,10 @@ function search_api_admin_index_fields(array $form, array &$form_state, SearchAp
     }
   }
 
+  if (!empty($multi_valued_field_present)) {
+    $form['note']['#markup'] = '<div id="note-multi-valued"><small><sup>1</sup> ' . t('Multi-valued field') . '</small></div>';
+  }
+
   $form['submit'] = array(
     '#type' => 'submit',
     '#value' => t('Save changes'),
@@ -1745,7 +1757,7 @@ function _search_api_admin_get_fields(SearchApiIndex $index, EntityMetadataWrapp
  */
 function theme_search_api_admin_fields_table($variables) {
   $form = $variables['element'];
-  $header = array(t('Field'), t('Indexed'), t('Type'), t('Boost'));
+  $header = array(t('Field'), t('Machine name'), t('Indexed'), t('Type'), t('Boost'));
 
   $rows = array();
   foreach (element_children($form['fields']) as $name) {
@@ -1766,11 +1778,13 @@ function theme_search_api_admin_fields_table($variables) {
     }
   }
 
+  $note = isset($form['note']) ? $form['note'] : '';
   $submit = $form['submit'];
   $additional = isset($form['additional']) ? $form['additional'] : FALSE;
-  unset($form['submit'], $form['additional']);
+  unset($form['note'], $form['submit'], $form['additional']);
   $output = drupal_render_children($form);
   $output .= theme('table', array('header' => $header, 'rows' => $rows));
+  $output .= render($note);
   $output .= render($submit);
   if ($additional) {
     $output .= render($additional);

+ 33 - 9
search_api.api.php

@@ -15,8 +15,6 @@
  *
  * Note: The ids should be valid PHP identifiers.
  *
- * @see hook_search_api_service_info_alter()
- *
  * @return array
  *   An associative array of search service classes, keyed by a unique
  *   identifier and containing associative arrays with the following keys:
@@ -27,6 +25,8 @@
  *     the "direct" parse mode and other specific things to keep in mind.
  *   - class: The service class, which has to implement the
  *     SearchApiServiceInterface interface.
+ *
+ * @see hook_search_api_service_info_alter()
  */
 function hook_search_api_service_info() {
   $services['example_some'] = array(
@@ -49,13 +49,14 @@ function hook_search_api_service_info() {
  * Alter the Search API service info.
  *
  * Modules may implement this hook to alter the information that defines Search
- * API service. All properties that are available in
- * hook_search_api_service_info() can be altered here.
- *
- * @see hook_search_api_service_info()
+ * API services. All properties that are available in
+ * hook_search_api_service_info() can be altered here, with the addition of the
+ * "module" key specifying the module that originally defined the service class.
  *
  * @param array $service_info
  *   The Search API service info array, keyed by service id.
+ *
+ * @see hook_search_api_service_info()
  */
 function hook_search_api_service_info_alter(array &$service_info) {
   foreach ($service_info as $id => $info) {
@@ -95,6 +96,9 @@ function hook_search_api_service_info_alter(array &$service_info) {
  *   - datasource controller: A class implementing the
  *     SearchApiDataSourceControllerInterface interface which will be used as
  *     the data source controller for this type.
+ *   - entity_type: (optional) If the type represents entities, the entity type.
+ *     This is used by SearchApiAbstractDataSourceController for determining the
+ *     entity type of items. Other datasource controllers might ignore this.
  *   Other, datasource-specific settings might also be placed here. These should
  *   be specified with the data source controller in question.
  *
@@ -109,6 +113,7 @@ function hook_search_api_item_type_info() {
       $types[$type] = array(
         'name' => $info['label'],
         'datasource controller' => 'SearchApiEntityDataSourceController',
+        'entity_type' => $type,
       );
     }
   }
@@ -121,7 +126,8 @@ function hook_search_api_item_type_info() {
  *
  * Modules may implement this hook to alter the information that defines an
  * item type. All properties that are available in
- * hook_search_api_item_type_info() can be altered here.
+ * hook_search_api_item_type_info() can be altered here, with the addition of
+ * the "module" key specifying the module that originally defined the type.
  *
  * @param array $infos
  *   The item type info array, keyed by type identifier.
@@ -278,6 +284,21 @@ function hook_search_api_index_items_alter(array &$items, SearchApiIndex $index)
   example_store_indexed_entity_ids($index->item_type, array_keys($items));
 }
 
+/**
+ * Allows modules to react after items were indexed.
+ *
+ * @param SearchApiIndex $index
+ *   The used index.
+ * @param array $item_ids
+ *   An array containing the indexed items' IDs.
+ */
+function hook_search_api_items_indexed(SearchApiIndex $index, array $item_ids) {
+  if ($index->getEntityType() == 'node') {
+    // Flush page cache of the search page.
+    cache_clear_all(url('search'), 'cache_page');
+  }
+}
+
 /**
  * Lets modules alter a search query before executing it.
  *
@@ -285,8 +306,11 @@ function hook_search_api_index_items_alter(array &$items, SearchApiIndex $index)
  *   The SearchApiQueryInterface object representing the search query.
  */
 function hook_search_api_query_alter(SearchApiQueryInterface $query) {
-  $info = entity_get_info($index->item_type);
-  $query->condition($info['entity keys']['id'], 0, '!=');
+  // Exclude entities with ID 0. (Assume the ID field is always indexed.)
+  if ($query->getIndex()->getEntityType()) {
+    $info = entity_get_info($query->getIndex()->getEntityType());
+    $query->condition($info['entity keys']['id'], 0, '!=');
+  }
 }
 
 /**

+ 4 - 0
search_api.drush.inc

@@ -184,6 +184,10 @@ function drush_search_api_index($index_id = NULL, $limit = NULL, $batch_size = N
     $datasource = $index->datasource();
     $index_status = $datasource->getIndexStatus($index);
     $remaining = $index_status['total'] - $index_status['indexed'];
+    if ($remaining <= 0) {
+      drush_log(dt("The index !index is up to date.", array('!index' => $index->name)), 'ok');
+      continue;
+    }
 
     // Get the number of items to index per batch run.
     if (!isset($batch_size)) {

+ 6 - 3
search_api.info

@@ -14,25 +14,28 @@ files[] = includes/callback_bundle_filter.inc
 files[] = includes/callback_language_control.inc
 files[] = includes/callback_node_access.inc
 files[] = includes/callback_node_status.inc
+files[] = includes/callback_role_filter.inc
 files[] = includes/datasource.inc
 files[] = includes/datasource_entity.inc
 files[] = includes/datasource_external.inc
 files[] = includes/exception.inc
 files[] = includes/index_entity.inc
 files[] = includes/processor.inc
+files[] = includes/processor_highlight.inc
 files[] = includes/processor_html_filter.inc
 files[] = includes/processor_ignore_case.inc
 files[] = includes/processor_stopwords.inc
 files[] = includes/processor_tokenizer.inc
+files[] = includes/processor_transliteration.inc
 files[] = includes/query.inc
 files[] = includes/server_entity.inc
 files[] = includes/service.inc
 
 configure = admin/config/search/search_api
 
-; Information added by drupal.org packaging script on 2013-01-09
-version = "7.x-1.4"
+; Information added by drupal.org packaging script on 2013-09-01
+version = "7.x-1.8"
 core = "7.x"
 project = "search_api"
-datestamp = "1357726719"
+datestamp = "1378025826"
 

+ 9 - 2
search_api.install

@@ -214,6 +214,7 @@ function search_api_install() {
     'server' => NULL,
     'item_type' => 'node',
     'options' => array(
+      'index_directly' => 1,
       'cron_limit' => '50',
       'data_alter_callbacks' => array(
         'search_api_alter_node_access' => array(
@@ -321,9 +322,15 @@ function search_api_disable() {
     $types[$index->item_type][] = $index;
   }
   foreach ($types as $type => $indexes) {
-    $controller = search_api_get_datasource_controller($type);
-    $controller->stopTracking($indexes);
+    try {
+      $controller = search_api_get_datasource_controller($type);
+      $controller->stopTracking($indexes);
+    }
+    catch (SearchApiException $e) {
+      // Modules defining entity or item types might have been disabled. Ignore.
+    }
   }
+  DrupalQueue::get('search_api_indexing_queue')->deleteQueue();
 }
 
 /**

+ 433 - 60
search_api.module

@@ -153,6 +153,42 @@ function search_api_menu() {
   return $items;
 }
 
+/**
+ * Implements hook_hook_info().
+ */
+function search_api_hook_info() {
+  // We use the same group for all hooks, so save code lines.
+  $hook_info = array(
+    'group' => 'search_api',
+  );
+  return array(
+    'search_api_service_info' => $hook_info,
+    'search_api_service_info_alter' => $hook_info,
+    'search_api_item_type_info' => $hook_info,
+    'search_api_item_type_info_alter' => $hook_info,
+    'search_api_data_type_info' => $hook_info,
+    'search_api_data_type_info_alter' => $hook_info,
+    'search_api_alter_callback_info' => $hook_info,
+    'search_api_processor_info' => $hook_info,
+    'search_api_index_items_alter' => $hook_info,
+    'search_api_items_indexed' => $hook_info,
+    'search_api_query_alter' => $hook_info,
+    'search_api_server_load' => $hook_info,
+    'search_api_server_insert' => $hook_info,
+    'search_api_server_update' => $hook_info,
+    'search_api_server_delete' => $hook_info,
+    'default_search_api_server' => $hook_info,
+    'default_search_api_server_alter' => $hook_info,
+    'search_api_index_load' => $hook_info,
+    'search_api_index_insert' => $hook_info,
+    'search_api_index_update' => $hook_info,
+    'search_api_index_reindex' => $hook_info,
+    'search_api_index_delete' => $hook_info,
+    'default_search_api_index' => $hook_info,
+    'default_search_api_index_alter' => $hook_info,
+  );
+}
+
 /**
  * Implements hook_theme().
  */
@@ -227,7 +263,9 @@ function search_api_cron() {
     if ($limit) {
       try {
         $task = array('index' => $index->machine_name);
-        $ids = search_api_get_items_to_index($index, -1);
+        // 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;
         }
@@ -417,6 +455,15 @@ function search_api_entity_property_info() {
  * Calls the postCreate() method for the server.
  */
 function search_api_search_api_server_insert(SearchApiServer $server) {
+  // Check whether this is actually part of a revert.
+  $reverts = &drupal_static('search_api_search_api_server_delete', array());
+  if (isset($reverts[$server->machine_name])) {
+    $server->original = $reverts[$server->machine_name];
+    unset($reverts[$server->machine_name]);
+    search_api_search_api_server_update($server);
+    unset($server->original);
+    return;
+  }
   $server->postCreate();
 }
 
@@ -432,7 +479,7 @@ function search_api_search_api_server_update(SearchApiServer $server) {
       $index->reindex();
     }
   }
-  if ($server->enabled != $server->original->enabled) {
+  if (!empty($server->original) && $server->enabled != $server->original->enabled) {
     if ($server->enabled) {
       // Were there any changes in the server's indexes while it was disabled?
       $tasks = variable_get('search_api_tasks', array());
@@ -448,7 +495,8 @@ function search_api_search_api_server_update(SearchApiServer $server) {
                 $server->deleteItems('all', $index);
                 break;
               case 'clear all':
-                // Would normally be used with a fake index ID of "", since it doesn't matter.
+                // Would normally be used with a fake index ID of "", since it
+                // doesn't matter.
                 $server->deleteItems('all');
                 break;
               case 'fields':
@@ -488,14 +536,16 @@ function search_api_search_api_server_update(SearchApiServer $server) {
  * Calls the preDelete() method for the server.
  */
 function search_api_search_api_server_delete(SearchApiServer $server) {
-  $server->preDelete();
-
-
   // Only react on real delete, not revert.
-  if (!$server->hasStatus(ENTITY_IN_CODE)) {
-    foreach (search_api_index_load_multiple(FALSE, array('server' => $server->machine_name)) as $index) {
-      $index->update(array('server' => NULL, 'enabled' => FALSE));
-    }
+  if ($server->hasStatus(ENTITY_IN_CODE)) {
+    $reverts = &drupal_static(__FUNCTION__, array());
+    $reverts[$server->machine_name] = $server;
+    return;
+  }
+
+  $server->preDelete();
+  foreach (search_api_index_load_multiple(FALSE, array('server' => $server->machine_name)) as $index) {
+    $index->update(array('server' => NULL, 'enabled' => FALSE));
   }
 
   $tasks = variable_get('search_api_tasks', array());
@@ -510,6 +560,16 @@ function search_api_search_api_server_delete(SearchApiServer $server) {
  * the index is enabled).
  */
 function search_api_search_api_index_insert(SearchApiIndex $index) {
+  // Check whether this is actually part of a revert.
+  $reverts = &drupal_static('search_api_search_api_index_delete', array());
+  if (isset($reverts[$index->machine_name])) {
+    $index->original = $reverts[$index->machine_name];
+    unset($reverts[$index->machine_name]);
+    search_api_search_api_index_update($index);
+    unset($index->original);
+    return;
+  }
+
   $index->postCreate();
 }
 
@@ -517,6 +577,9 @@ function search_api_search_api_index_insert(SearchApiIndex $index) {
  * Implements hook_search_api_index_update().
  */
 function search_api_search_api_index_update(SearchApiIndex $index) {
+  // Call the datasource update function with the table this module provides.
+  search_api_index_update_datasource($index, 'search_api_item');
+
   // If the server was changed, we have to call the appropriate service class
   // hook methods.
   if ($index->server != $index->original->server) {
@@ -552,22 +615,33 @@ function search_api_search_api_index_update(SearchApiIndex $index) {
       }
     }
 
-    // We also have to re-index all content
+    // We also have to re-index all content.
     _search_api_index_reindex($index);
   }
 
   // If the fields were changed, call the appropriate service class hook method
-  // and re-index the content, if necessary.
+  // and re-index the content, if necessary. Also, clear the fields cache.
   $old_fields = $index->original->options + array('fields' => array());
   $old_fields = $old_fields['fields'];
   $new_fields = $index->options + array('fields' => array());
   $new_fields = $new_fields['fields'];
   if ($old_fields != $new_fields) {
+    cache_clear_all($index->getCacheId(), 'cache', TRUE);
     if ($index->server && $index->server()->fieldsUpdated($index)) {
       _search_api_index_reindex($index);
     }
   }
 
+  // If additional fields changed, clear the index's specific cache which
+  // includes them.
+  $old_additional = $index->original->options + array('additional fields' => array());
+  $old_additional = $old_additional['additional fields'];
+  $new_additional = $index->options + array('additional fields' => array());
+  $new_additional = $new_additional['additional fields'];
+  if ($old_additional != $new_additional) {
+    cache_clear_all($index->getCacheId() . '-0-1', 'cache');
+  }
+
   // If the index's enabled or read-only status is being changed, queue or
   // dequeue items for indexing.
   if (!$index->read_only && $index->enabled != $index->original->enabled) {
@@ -603,6 +677,13 @@ function search_api_search_api_index_update(SearchApiIndex $index) {
  * Removes all data for indexes not available any more.
  */
 function search_api_search_api_index_delete(SearchApiIndex $index) {
+  // Only react on real delete, not revert.
+  if ($index->hasStatus(ENTITY_IN_CODE)) {
+    $reverts = &drupal_static(__FUNCTION__, array());
+    $reverts[$index->machine_name] = $index;
+    return;
+  }
+  cache_clear_all($index->getCacheId(''), 'cache', TRUE);
   $index->postDelete();
 }
 
@@ -642,6 +723,72 @@ function search_api_features_export_alter(&$export, $module_name) {
   }
 }
 
+/**
+ * Implements hook_system_info_alter().
+ *
+ * Checks if the module provides any search item types or service classes. If it
+ * does, and there are search indexes using those item types, respectively
+ * servers using those service classes, the module is set to "required".
+ *
+ * Heavily borrowed from field_system_info_alter().
+ *
+ * @see hook_search_api_item_type_info()
+ */
+function search_api_system_info_alter(&$info, $file, $type) {
+  if ($type != 'module' || $file->name == 'search_api') {
+    return;
+  }
+  // Check for defined item types.
+  if (module_hook($file->name, 'search_api_item_type_info')) {
+    $types = array();
+    foreach (search_api_get_item_type_info() as $type => $type_info) {
+      if ($type_info['module'] == $file->name) {
+        $types[] = $type;
+      }
+    }
+    if ($types) {
+      $sql = 'SELECT machine_name, name FROM {search_api_index} WHERE item_type IN (:types)';
+      $indexes = db_query($sql, array(':types' => $types))->fetchAllKeyed();
+      if ($indexes) {
+        $info['required'] = TRUE;
+
+        $links = array();
+        foreach ($indexes as $id => $name) {
+          $links[] = l($name, "admin/config/search/search_api/index/$id");
+        }
+
+        $args = array('!indexes' => implode(', ', $links));
+        $info['explanation'] = format_plural(count($indexes), 'Item type in use by the following index: !indexes.', 'Item type(s) in use by the following indexes: !indexes.', $args);
+      }
+    }
+  }
+  // Check for defined service classes.
+  if (module_hook($file->name, 'search_api_service_info')) {
+    $classes = array();
+    foreach (search_api_get_service_info() as $class => $class_info) {
+      if ($class_info['module'] == $file->name) {
+        $classes[] = $class;
+      }
+    }
+    if ($classes) {
+      $sql = 'SELECT machine_name, name FROM {search_api_server} WHERE class IN (:classes)';
+      $servers = db_query($sql, array(':classes' => $classes))->fetchAllKeyed();
+      if ($servers) {
+        $info['required'] = TRUE;
+
+        $links = array();
+        foreach ($servers as $id => $name) {
+          $links[] = l($name, "admin/config/search/search_api/server/$id");
+        }
+
+        $args = array('!servers' => implode(', ', $links));
+        $explanation = format_plural(count($servers), 'Service class in use by the following server: !servers.', 'Service class(es) in use by the following servers: !servers.', $args);
+        $info['explanation'] = (!empty($info['explanation']) ? $info['explanation'] . ' ' : '') . $explanation;
+      }
+    }
+  }
+}
+
 /**
  * Implements hook_entity_insert().
  *
@@ -712,6 +859,32 @@ function search_api_entity_delete($entity, $type) {
   }
 }
 
+/**
+ * Implements hook_field_update_field().
+ *
+ * Recalculates fields settings if the cardinality of the field has changed from
+ * or to 1.
+ */
+function search_api_field_update_field($field, $prior_field, $has_data) {
+  $before = $prior_field['cardinality'];
+  $after = $field['cardinality'];
+  if ($before != $after && ($before == 1 || $after == 1)) {
+    // Unfortunately, we cannot call this right away since the field information
+    // is only stored after the hook is called.
+    drupal_register_shutdown_function('search_api_index_recalculate_fields');
+  }
+}
+
+/**
+ * Implements hook_flush_caches().
+ *
+ * Recalculates fields settings in case the schema (in most cases: the
+ * multiplicity) of a property has changed.
+ */
+function search_api_flush_caches() {
+  search_api_index_recalculate_fields();
+}
+
 /**
  * Implements hook_search_api_item_type_info().
  *
@@ -725,6 +898,7 @@ function search_api_search_api_item_type_info() {
       $types[$type] = array(
         'name' => $info['label'],
         'datasource controller' => 'SearchApiEntityDataSourceController',
+        'entity_type' => $type,
       );
     }
   }
@@ -736,18 +910,20 @@ function search_api_search_api_item_type_info() {
  * Implements hook_modules_enabled().
  */
 function search_api_modules_enabled(array $modules) {
-  // New modules might offer additional entity types, invalidating the cached
-  // item type information.
+  // New modules might offer additional item types or service classes,
+  // invalidating the cached information.
   drupal_static_reset('search_api_get_item_type_info');
+  drupal_static_reset('search_api_get_service_info');
 }
 
 /**
  * Implements hook_modules_disabled().
  */
 function search_api_modules_disabled(array $modules) {
-  // The disabled modules might have offered entity types, which are now
-  // invalid. Therefore, clear the cached item type informaiton.
+  // The disabled modules might have offered item types or service classes,
+  // invalidating the cached information.
   drupal_static_reset('search_api_get_item_type_info');
+  drupal_static_reset('search_api_get_service_info');
 }
 
 /**
@@ -761,6 +937,13 @@ function search_api_search_api_alter_callback_info() {
     // Filters should be executed first.
     'weight' => -10,
   );
+  $callbacks['search_api_alter_role_filter'] = array(
+    'name' => t('Role filter'),
+    'description' => t('Exclude users from indexing based on their role.'),
+    'class' => 'SearchApiAlterRoleFilter',
+    // Filters should be executed first.
+    'weight' => -10,
+  );
   $callbacks['search_api_alter_add_url'] = array(
     'name' => t('URL field'),
     'description' => t("Adds the item's URL to the indexed data."),
@@ -806,7 +989,7 @@ function search_api_search_api_alter_callback_info() {
 function search_api_search_api_processor_info() {
   $processors['search_api_case_ignore'] = array(
     'name' => t('Ignore case'),
-    'description' => t('This processor will make searches case-insensitive for all fulltext fields (and, optionally, also for filters on string fields).'),
+    'description' => t('This processor will make searches case-insensitive for fulltext or string fields.'),
     'class' => 'SearchApiIgnoreCase',
   );
   $processors['search_api_html_filter'] = array(
@@ -817,6 +1000,14 @@ function search_api_search_api_processor_info() {
     'class' => 'SearchApiHtmlFilter',
     'weight' => 10,
   );
+  if (module_exists('transliteration')) {
+    $processors['search_api_transliteration'] = array(
+      'name' => t('Transliteration'),
+      'description' => t('This processor will make searches insensitive to accents and other non-ASCII characters.'),
+      'class' => 'SearchApiTransliteration',
+      'weight' => 15,
+    );
+  }
   $processors['search_api_tokenizer'] = array(
     'name' => t('Tokenizer'),
     'description' => t('Tokenizes fulltext data by stripping whitespace. ' .
@@ -832,6 +1023,12 @@ function search_api_search_api_processor_info() {
     'class' => 'SearchApiStopWords',
     'weight' => 30,
   );
+  $processors['search_api_highlighting'] = array(
+    'name' => t('Highlighting'),
+    'description' => t('Adds highlighting for search results.'),
+    'class' => 'SearchApiHighlight',
+    'weight' => 35,
+  );
 
   return $processors;
 }
@@ -859,7 +1056,7 @@ function search_api_track_item_insert($type, array $item_ids) {
 
   foreach ($indexes as $index) {
     if (!empty($index->options['index_directly'])) {
-      $indexed = search_api_index_specific_items_delayed($index, $item_ids);
+      search_api_index_specific_items_delayed($index, $item_ids);
     }
   }
 }
@@ -922,6 +1119,7 @@ function search_api_track_item_queued(SearchApiIndex $index, array $item_ids) {
  */
 function search_api_track_item_indexed(SearchApiIndex $index, array $item_ids) {
   $index->datasource()->trackItemIndexed($item_ids, $index);
+  module_invoke_all('search_api_items_indexed', $index, $item_ids);
 }
 
 /**
@@ -964,6 +1162,83 @@ function search_api_track_item_delete($type, array $item_ids) {
   }
 }
 
+/**
+ * Recalculates the saved fields of an index.
+ *
+ * This is mostly necessary when the multiplicity of the underlying properties
+ * change. The method will re-examine the data structure of the entities in each
+ * index and, if a discrepancy is spotted, re-save that index with updated
+ * fields options (thus, of course, also triggering a re-indexing operation).
+ *
+ * @param array|false $indexes
+ *   An array of SearchApiIndex objects on which to perform the operation, or
+ *   FALSE to perform it on all indexes.
+ */
+function search_api_index_recalculate_fields($indexes = FALSE) {
+  if (!is_array($indexes)) {
+    $indexes = search_api_index_load_multiple(FALSE);
+  }
+  $stored_keys = drupal_map_assoc(array('type', 'entity_type', 'real_type', 'boost'));
+  foreach ($indexes as $index) {
+    if (empty($index->options['fields'])) {
+      continue;
+    }
+    // We have to clear the cache, both static and stored, before using
+    // getFields(). Otherwise, we'd just use the stale data which the fields
+    // options are probably already based on.
+    cache_clear_all($index->getCacheId() . '-1-0', 'cache');
+    $index->resetCaches();
+    // getFields() automatically uses the actual data types to correct possible
+    // stale data.
+    $fields = $index->getFields();
+    foreach ($fields as $key => $field) {
+      $fields[$key] = array_intersect_key($field, $stored_keys);
+      if (isset($fields[$key]['boost']) && $fields[$key]['boost'] == '1.0') {
+        unset($fields[$key]['boost']);
+      }
+    }
+    // Use a more accurate method of determining if the fields settings are
+    // equal to avoid needlessly re-indexing the whole index.
+    if (!_search_api_settings_equals($fields, $index->options['fields'])) {
+      $options = $index->options;
+      $options['fields'] = $fields;
+      $index->update(array('options' => $options));
+    }
+  }
+}
+
+/**
+ * Test two setting arrays (or individual settings) for equality.
+ *
+ * While a simple == also works in some cases, this function takes into account
+ * that the order of keys (usually) doesn't matter in settings arrays.
+ *
+ * @param mixed $setting1
+ *   The first setting (array).
+ * @param mixed $setting2
+ *   The second setting (array).
+ *
+ * @return bool
+ *   TRUE if both settings are identical, FALSE otherwise.
+ */
+function _search_api_settings_equals($setting1, $setting2) {
+  if (!is_array($setting1) || !is_array($setting2)) {
+    return $setting1 == $setting2;
+  }
+  foreach ($setting1 as $key => $value) {
+    if (!array_key_exists($key, $setting2)) {
+      return FALSE;
+    }
+    if (!_search_api_settings_equals($value, $setting2[$key])) {
+      return FALSE;
+    }
+    unset($setting2[$key]);
+  }
+  // If any keys weren't unset previously, they are not present in $setting1 and
+  // the two are different.
+  return !$setting2;
+}
+
 /**
  * Indexes items for the specified index. Only items marked as changed are
  * indexed, in their order of change (if known).
@@ -1035,7 +1310,21 @@ function search_api_index_specific_items(SearchApiIndex $index, array $ids) {
   // Clone items because data alterations may alter them.
   $cloned_items = array();
   foreach ($items as $id => $item) {
-    $cloned_items[$id] = clone $item;
+    if (is_object($item)) {
+      $cloned_items[$id] = clone $item;
+    }
+    else {
+      // Normally, items that can't be loaded shouldn't be returned by
+      // entity_load (and other loadItems() implementations). Therefore, this is
+      // an extremely rare case, which seems to happen during installation for
+      // some specific setups.
+      $type = search_api_get_item_type_info($index->item_type);
+      $type = $type ? $type['name'] : $index->item_type;
+      watchdog('search_api',
+          "Error during indexing: invalid item loaded for @type with ID @id.",
+          array('@id' => $id, '@type' => $type),
+          WATCHDOG_WARNING);
+    }
   }
   $indexed = $items ? $index->index($cloned_items) : array();
   if ($indexed) {
@@ -1259,22 +1548,48 @@ function search_api_get_data_type_info($type = NULL) {
  *
  * @see hook_search_api_service_info()
  *
- * @param $id
+ * @param string|null $id
  *   The ID of the service info to retrieve.
  *
  * @return array
  *   If $id was not specified, an array of all available service classes.
  *   Otherwise, either the service info with the specified id (if it exists),
- *   or NULL.
+ *   or NULL. Service class information is formatted as specified by
+ *   hook_search_api_service_info(), with the addition of a "module" key
+ *   specifying the module that adds a certain class.
  */
 function search_api_get_service_info($id = NULL) {
   $services = &drupal_static(__FUNCTION__);
 
   if (!isset($services)) {
-    $services = module_invoke_all('search_api_service_info');
+    // Inlined version of module_invoke_all() to add "module" keys.
+    $services = array();
+    foreach (module_implements('search_api_service_info') as $module) {
+      $function = $module . '_search_api_service_info';
+      if (function_exists($function)) {
+        $new_services = $function();
+        if (isset($new_services) && is_array($new_services)) {
+          foreach ($new_services as $service => $info) {
+            $new_services[$service] += array('module' => $module);
+          }
+        }
+        $services += $new_services;
+      }
+    }
 
-    // Allow other modules to alter definitions
-    drupal_alter('search_api_service_info', $services);
+    // Same for drupal_alter().
+    foreach (module_implements('search_api_service_info_alter') as $module) {
+      $function = $module . '_search_api_service_info_alter';
+      if (function_exists($function)) {
+        $old = $services;
+        $function($services);
+        if ($new_services = array_diff_key($services, $old)) {
+          foreach ($new_services as $service => $info) {
+            $services[$service] += array('module' => $module);
+          }
+        }
+      }
+    }
   }
 
   if (isset($id)) {
@@ -1286,15 +1601,15 @@ function search_api_get_service_info($id = NULL) {
 /**
  * Returns information for either all item types, or a specific one.
  *
- * @param $type
+ * @param string|null $type
  *   If set, the item type whose information should be returned.
  *
- * @return
+ * @return array|null
  *   If $type is given, either an array containing the information of that item
  *   type, or NULL if it is unknown. Otherwise, an array keyed by type IDs
  *   containing the information for all item types. Item type information is
- *   formatted as specified by hook_search_api_item_type_info(), and has all
- *   optional fields filled with the defaults.
+ *   formatted as specified by hook_search_api_item_type_info(), with the
+ *   addition of a "module" key specifying the module that adds a certain type.
  *
  * @see hook_search_api_item_type_info()
  */
@@ -1302,8 +1617,34 @@ function search_api_get_item_type_info($type = NULL) {
   $types = &drupal_static(__FUNCTION__);
 
   if (!isset($types)) {
-    $types = module_invoke_all('search_api_item_type_info');
-    drupal_alter('search_api_item_type_info', $types);
+    // Inlined version of module_invoke_all() to add "module" keys.
+    $types = array();
+    foreach (module_implements('search_api_item_type_info') as $module) {
+      $function = $module . '_search_api_item_type_info';
+      if (function_exists($function)) {
+        $new_types = $function();
+        if (isset($new_types) && is_array($new_types)) {
+          foreach ($new_types as $id => $info) {
+            $new_types[$id] += array('module' => $module);
+          }
+        }
+        $types += $new_types;
+      }
+    }
+
+    // Same for drupal_alter().
+    foreach (module_implements('search_api_item_type_info_alter') as $module) {
+      $function = $module . '_search_api_item_type_info_alter';
+      if (function_exists($function)) {
+        $old = $types;
+        $function($types);
+        if ($new_types = array_diff_key($types, $old)) {
+          foreach ($new_types as $id => $info) {
+            $types[$id] += array('module' => $module);
+          }
+        }
+      }
+    }
   }
 
   if (isset($type)) {
@@ -1444,7 +1785,7 @@ function _search_api_query_add_node_access($account, SearchApiQueryInterface $qu
       $query->filter($filter);
     }
     else {
-      // $query->condition('status', NODE_PUBLISHED);
+      $query->condition('status', NODE_PUBLISHED);
     }
     // Filter by node access grants.
     $filter = $query->createFilter('OR');
@@ -1552,6 +1893,38 @@ function search_api_extract_inner_type($type) {
   return $type;
 }
 
+/**
+ * Helper function for reacting to index updates with regards to the datasource.
+ *
+ * When an overridden index is reverted, its numerical ID will sometimes change.
+ * Since the default datasource implementation uses that for referencing
+ * indexes, the index ID in the items table must be updated accordingly. This is
+ * implemented in this function.
+ *
+ * Modules implementing other datasource controllers, that use a table other
+ * than {search_api_item}, can use this function, too. It should be called
+ * uncoditionally in a hook_search_api_index_update() implementation. If this
+ * function isn't used, similar code should be added there.
+ *
+ * However, note that this is only necessary (and this function should only be
+ * called) if the indexes are referenced by numerical ID in the items table.
+ *
+ * @param SearchApiIndex $index
+ *   The index that was changed.
+ * @param string $table
+ *   The table containing items information, analogous to {search_api_item}.
+ * @param string $column
+ *   The column in $table that holds the index's numerical ID.
+ */
+function search_api_index_update_datasource(SearchApiIndex $index, $table, $column = 'index_id') {
+  if ($index->id != $index->original->id) {
+    db_update($table)
+      ->fields(array($column => $index->id))
+      ->condition($column, $index->original->id)
+      ->execute();
+  }
+}
+
 /**
  * Utility function for extracting specific fields from an EntityMetadataWrapper
  * object.
@@ -1605,26 +1978,28 @@ function search_api_extract_fields(EntityMetadataWrapper $wrapper, array $fields
       // Set "defaults" in case an error occurs later.
       $info['value'] = NULL;
       $info['original_type'] = $info['type'];
-      try {
-        $info['value'] = $wrapper->$field->value($value_options);
-        // For fulltext fields with options, also include the option labels.
-        if (search_api_is_text_type($info['type']) && $wrapper->$field->optionsList('view')) {
-          _search_api_add_option_values($info['value'], $wrapper->$field->optionsList('view'));
+      if (isset($wrapper->$field)) {
+        try {
+          $info['value'] = $wrapper->$field->value($value_options);
+          // For fulltext fields with options, also include the option labels.
+          if (search_api_is_text_type($info['type']) && $wrapper->$field->optionsList('view')) {
+            _search_api_add_option_values($info['value'], $wrapper->$field->optionsList('view'));
+          }
+          $property_info = $wrapper->$field->info();
+          $info['original_type'] = $property_info['type'];
+          // For entities, we extract the entity ID instead of the whole object.
+          // @todo Use 'identifier' => TRUE instead of always loading the object.
+          $t = search_api_extract_inner_type($property_info['type']);
+          if (isset($entity_infos[$t])) {
+            // If no object is set, set this field to NULL.
+            $info['value'] = $info['value'] ? _search_api_extract_entity_value($wrapper->$field, search_api_is_text_type($info['type'])) : NULL;
+          }
         }
-        $property_info = $wrapper->$field->info();
-        $info['original_type'] = $property_info['type'];
-        // For entities, we extract the entity ID instead of the whole object.
-        // @todo Use 'identifier' => TRUE instead of always loading the object.
-        $t = search_api_extract_inner_type($property_info['type']);
-        if (isset($entity_infos[$t])) {
-          // If no object is set, set this field to NULL.
-          $info['value'] = $info['value'] ? _search_api_extract_entity_value($wrapper->$field, search_api_is_text_type($info['type'])) : NULL;
+        catch (EntityMetadataWrapperException $e) {
+          // This might happen for entity-typed properties that are NULL, e.g.,
+          // for comments without parent.
         }
       }
-      catch (EntityMetadataWrapperException $e) {
-        // This might happen for entity-typed properties that are NULL, e.g.,
-        // for comments without parent.
-      }
     }
     else {
       list($prefix, $key) = explode(':', $field, 2);
@@ -1636,10 +2011,6 @@ function search_api_extract_fields(EntityMetadataWrapper $wrapper, array $fields
   foreach ($nested as $prefix => $nested_fields) {
     if (isset($wrapper->$prefix)) {
       $nested_fields = search_api_extract_fields($wrapper->$prefix, $nested_fields, $value_options);
-      # http://drupal.org/node/1873910#comment-6876200
-      // $subwrapper = $wrapper->$prefix;
-      // $subwrapper->language( $wrapper->language->value() );
-      // $nested_fields = search_api_extract_fields($subwrapper, $nested_fields, $value_options);
       foreach ($nested_fields as $field => $info) {
         $fields["$prefix:$field"] = $info;
       }
@@ -1716,19 +2087,20 @@ function search_api_server_load($id, $reset = FALSE) {
  *
  * @see entity_load()
  *
- * @param $ids
+ * @param array|false $ids
  *   An array of server IDs or machine names, or FALSE to load all servers.
- * @param $conditions
+ * @param array $conditions
  *   An array of conditions on the {search_api_server} table in the form
  *   'field' => $value.
- * @param $reset
+ * @param bool $reset
  *   Whether to reset the internal entity_load cache.
  *
  * @return array
  *   An array of server objects keyed by machine name.
  */
 function search_api_server_load_multiple($ids = array(), $conditions = array(), $reset = FALSE) {
-  return entity_load_multiple_by_name('search_api_server', $ids, $conditions, $reset);
+  $servers = entity_load('search_api_server', $ids, $conditions, $reset);
+  return entity_key_array_by_property($servers, 'machine_name');
 }
 
 /**
@@ -1872,12 +2244,12 @@ function search_api_index_load($id, $reset = FALSE) {
  *
  * @see entity_load()
  *
- * @param $ids
+ * @param array|false $ids
  *   An array of index IDs or machine names, or FALSE to load all indexes.
- * @param $conditions
+ * @param array $conditions
  *   An array of conditions on the {search_api_index} table in the form
  *   'field' => $value.
- * @param $reset
+ * @param bool $reset
  *   Whether to reset the internal entity_load cache.
  *
  * @return array
@@ -1887,7 +2259,8 @@ function search_api_index_load_multiple($ids = array(), $conditions = array(), $
   // This line is a workaround for a weird PDO bug in PHP 5.2.
   // See http://drupal.org/node/889286.
   new SearchApiIndex();
-  return entity_load_multiple_by_name('search_api_index', $ids, $conditions, $reset);
+  $indexes = entity_load('search_api_index', $ids, $conditions, $reset);
+  return entity_key_array_by_property($indexes, 'machine_name');
 }
 
 /**

+ 1 - 2
search_api.rules.inc

@@ -60,7 +60,6 @@ function _search_api_rules_action_index(EntityDrupalWrapper $wrapper, SearchApiI
     return;
   }
 
-
   if ($index) {
     $indexes = array($index);
   }
@@ -77,7 +76,7 @@ function _search_api_rules_action_index(EntityDrupalWrapper $wrapper, SearchApiI
   }
   if ($index_immediately) {
     foreach ($indexes as $index) {
-      $indexed = search_api_index_specific_items_delayed($index, $item_ids);
+      search_api_index_specific_items_delayed($index, $item_ids);
     }
   }
   else {

+ 107 - 91
search_api.test

@@ -14,13 +14,13 @@ class SearchApiWebTest extends DrupalWebTestCase {
 
   protected function drupalGet($path, array $options = array(), array $headers = array()) {
     $ret = parent::drupalGet($path, $options, $headers);
-    $this->assertResponse(200, t('HTTP code 200 returned.'));
+    $this->assertResponse(200, 'HTTP code 200 returned.');
     return $ret;
   }
 
   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, t('HTTP code 200 returned.'));
+    $this->assertResponse(200, 'HTTP code 200 returned.');
     return $ret;
   }
 
@@ -54,6 +54,7 @@ class SearchApiWebTest extends DrupalWebTestCase {
     $this->clearIndex();
     $this->searchNoResults();
     $this->deleteServer();
+    $this->disableModules();
   }
 
   protected function deleteDefaultIndex() {
@@ -93,7 +94,7 @@ class SearchApiWebTest extends DrupalWebTestCase {
       'type' => 'Page',
     ));
     $count = db_query('SELECT COUNT(*) FROM {search_api_test}')->fetchField() - $count;
-    $this->assertEqual($count, 5, t('@count items inserted.', array('@count' => $count)));
+    $this->assertEqual($count, 5, '5 items successfully inserted.');
   }
 
   protected function insertItem($values) {
@@ -104,7 +105,7 @@ class SearchApiWebTest extends DrupalWebTestCase {
     // 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.'), t('"No servers" message is displayed.'));
+    //$this->assertText(t('There are no search servers or indexes defined yet.'), '"No servers" message is displayed.');
   }
 
   protected function createIndex() {
@@ -132,23 +133,23 @@ class SearchApiWebTest extends DrupalWebTestCase {
     );
     $this->drupalPost(NULL, $values, t('Create index'));
 
-    $this->assertText(t('The index was successfully created. Please set up its indexed fields now.'), t('The index was successfully created.'));
+    $this->assertText(t('The index was successfully created. Please set up its indexed fields now.'), 'The index was successfully created.');
     $found = strpos($this->getUrl(), 'admin/config/search/search_api/index/' . $id) !== FALSE;
-    $this->assertTrue($found, t('Correct redirect.'));
+    $this->assertTrue($found, 'Correct redirect.');
     $index = search_api_index_load($id, TRUE);
-    $this->assertEqual($index->name, $values['name'], t('Name correctly inserted.'));
-    $this->assertEqual($index->item_type, $values['item_type'], t('Index item type correctly inserted.'));
-    $this->assertFalse($index->enabled, t('Status correctly inserted.'));
-    $this->assertEqual($index->description, $values['description'], t('Description correctly inserted.'));
-    $this->assertNull($index->server, t('Index server correctly inserted.'));
-    $this->assertEqual($index->options['cron_limit'], $values['options[cron_limit]'], t('Cron batch size correctly inserted.'));
+    $this->assertEqual($index->name, $values['name'], 'Name correctly inserted.');
+    $this->assertEqual($index->item_type, $values['item_type'], 'Index item type correctly inserted.');
+    $this->assertFalse($index->enabled, 'Status correctly inserted.');
+    $this->assertEqual($index->description, $values['description'], 'Description correctly inserted.');
+    $this->assertNull($index->server, 'Index server correctly inserted.');
+    $this->assertEqual($index->options['cron_limit'], $values['options[cron_limit]'], 'Cron batch size correctly inserted.');
 
     $values = array(
       'additional[field]' => 'parent',
     );
     $this->drupalPost("admin/config/search/search_api/index/$id/fields", $values, t('Add fields'));
-    $this->assertText(t('The available fields were successfully changed.'), t('Successfully added fields.'));
-    $this->assertText('Parent » ID', t('!field displayed.', array('!field' => t('Added fields are'))));
+    $this->assertText(t('The available fields were successfully changed.'), 'Successfully added fields.');
+    $this->assertText('Parent » ID', 'Added fields are displayed.');
 
     $values = array(
       'fields[id][type]' => 'integer',
@@ -177,7 +178,7 @@ class SearchApiWebTest extends DrupalWebTestCase {
       'fields[parent:type][indexed]' => 1,
     );
     $this->drupalPost(NULL, $values, t('Save changes'));
-    $this->assertText(t('The indexed fields were successfully changed. The index was cleared and will have to be re-indexed with the new settings.'), t('Field settings saved.'));
+    $this->assertText(t('The indexed fields were successfully changed. The index was cleared and will have to be re-indexed with the new settings.'), 'Field settings saved.');
 
     $values = array(
       'callbacks[search_api_alter_add_url][status]' => 1,
@@ -209,16 +210,16 @@ class SearchApiWebTest extends DrupalWebTestCase {
       'callbacks[search_api_alter_add_aggregation][settings][fields][search_api_aggregation_1][fields][parent:body]' => 1,
     );
     $this->drupalPost(NULL, $values, t('Save configuration'));
-    $this->assertText(t("The search index' workflow was successfully edited. All content was scheduled for re-indexing so the new settings can take effect."), t('Workflow successfully edited.'));
+    $this->assertText(t("The search index' workflow was successfully edited. All content was scheduled for re-indexing so the new settings can take effect."), 'Workflow successfully edited.');
 
     $this->drupalGet("admin/config/search/search_api/index/$id");
-    $this->assertTitle('Search API test index | Drupal', t('Correct title when viewing index.'));
-    $this->assertText('An index used for testing.', t('!field displayed.', array('!field' => t('Description'))));
-    $this->assertText('Search API test entity', t('!field displayed.', array('!field' => t('Item type'))));
-    $this->assertText(format_plural(1, '1 item per cron batch.', '@count items per cron batch.'), t('!field displayed.', array('!field' => t('Cron batch size'))));
+    $this->assertTitle('Search API test index | Drupal', 'Correct title when viewing index.');
+    $this->assertText('An index used for testing.', 'Description displayed.');
+    $this->assertText('Search API test entity', 'Item type displayed.');
+    $this->assertText(format_plural(1, '1 item per cron batch.', '@count items per cron batch.'), 'Cron batch size displayed.');
 
     $this->drupalGet("admin/config/search/search_api/index/$id/status");
-    $this->assertText(t('The index is currently disabled.'), t('"Disabled" status displayed.'));
+    $this->assertText(t('The index is currently disabled.'), '"Disabled" status displayed.');
   }
 
   protected function createServer() {
@@ -249,25 +250,25 @@ class SearchApiWebTest extends DrupalWebTestCase {
 
     $this->assertText(t('The server was successfully created.'));
     $found = strpos($this->getUrl(), 'admin/config/search/search_api/server/' . $id) !== FALSE;
-    $this->assertTrue($found, t('Correct redirect.'));
+    $this->assertTrue($found, 'Correct redirect.');
     $server = search_api_server_load($id, TRUE);
-    $this->assertEqual($server->name, $values['name'], t('Name correctly inserted.'));
-    $this->assertTrue($server->enabled, t('Status correctly inserted.'));
-    $this->assertEqual($server->description, $values['description'], t('Description correctly inserted.'));
-    $this->assertEqual($server->class, $values['class'], t('Service class correctly inserted.'));
-    $this->assertEqual($server->options['test'], $values2['options[form][test]'], t('Service options correctly inserted.'));
-    $this->assertTitle('Search API test server | Drupal', t('Correct title when viewing server.'));
-    $this->assertText('A server used for testing.', t('!field displayed.', array('!field' => t('Description'))));
-    $this->assertText('search_api_test_service', t('!field displayed.', array('!field' => t('Service name'))));
-    $this->assertText('search_api_test_service description', t('!field displayed.', array('!field' => t('Service description'))));
-    $this->assertText('search_api_test foo bar', t('!field displayed.', array('!field' => t('Service options'))));
+    $this->assertEqual($server->name, $values['name'], 'Name correctly inserted.');
+    $this->assertTrue($server->enabled, 'Status correctly inserted.');
+    $this->assertEqual($server->description, $values['description'], 'Description correctly inserted.');
+    $this->assertEqual($server->class, $values['class'], 'Service class correctly inserted.');
+    $this->assertEqual($server->options['test'], $values2['options[form][test]'], 'Service options correctly inserted.');
+    $this->assertTitle('Search API test server | Drupal', 'Correct title when viewing server.');
+    $this->assertText('A server used for testing.', 'Description displayed.');
+    $this->assertText('search_api_test_service', 'Service name displayed.');
+    $this->assertText('search_api_test_service description', 'Service description displayed.');
+    $this->assertText('search_api_test foo bar', 'Service options displayed.');
   }
 
   protected function checkOverview2() {
     $this->drupalGet('admin/config/search/search_api');
-    $this->assertText('Search API test server', t('!field displayed.', array('!field' => t('Server'))));
-    $this->assertText('Search API test index', t('!field displayed.', array('!field' => t('Index'))));
-    $this->assertNoText(t('There are no search servers or indexes defined yet.'), t('"No servers" message not displayed.'));
+    $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.');
   }
 
   protected function enableIndex() {
@@ -276,25 +277,25 @@ class SearchApiWebTest extends DrupalWebTestCase {
     );
     $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.'));
-    $this->assertText('Search API test server', t('!field displayed.', array('!field' => t('Server'))));
+    $this->assertText('Search API test server', 'Server displayed.');
 
-    $this->drupalGet("admin/config/search/search_api/index/{$this->index_id}/enable");
+    $this->clickLink(t('enable'));
     $this->assertText(t('The index was successfully enabled.'));
   }
 
   protected function searchNoResults() {
     $this->drupalGet('search_api_test/query/' . $this->index_id);
-    $this->assertText('result count = 0', t('No search results returned without indexing.'));
-    $this->assertText('results = ()', t('No search results returned without indexing.'));
+    $this->assertText('result count = 0', 'No search results returned without indexing.');
+    $this->assertText('results = ()', 'No search results returned without indexing.');
   }
 
   protected function indexItems() {
     $this->drupalGet("admin/config/search/search_api/index/{$this->index_id}/status");
-    $this->assertText(t('The index is currently enabled.'), t('"Enabled" status displayed.'));
-    $this->assertText(t('All items still need to be indexed (@total total).', array('@total' => 10)), t('!field displayed.', array('!field' => t('Correct index status'))));
-    $this->assertText(t('Index now'), t('"Index now" button found.'));
-    $this->assertText(t('Clear index'), t('"Clear index" button found.'));
-    $this->assertNoText(t('Re-index content'), t('"Re-index" button not found.'));
+    $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.
@@ -305,10 +306,10 @@ class SearchApiWebTest extends DrupalWebTestCase {
     );
     $this->drupalPost(NULL, $values, t('Index now'));
     $this->assertText(t('Successfully indexed @count items.', array('@count' => 7)));
-    $this->assertText(t('1 item could not be indexed. Check the logs for details.'), t('Index errors warning is displayed.'));
-    $this->assertNoText(t("Couldn't index items. Check the logs for details."), t("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)), t('!field displayed.', array('!field' => t('Correct index status'))));
-    $this->assertText(t('Re-indexing'), t('"Re-index" button found.'));
+    $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.');
 
     // Here we're testing the error message when no item could be indexed.
     // The item with ID 8 is still not indexed.
@@ -316,9 +317,9 @@ class SearchApiWebTest extends DrupalWebTestCase {
       'limit' => 1,
     );
     $this->drupalPost(NULL, $values, t('Index now'));
-    $this->assertNoPattern('/' . str_replace('144', '-?\d*', t('Successfully indexed @count items.', array('@count' => 144))) . '/', t('No items could be indexed.'));
-    $this->assertNoText(t('1 item could not be indexed. Check the logs for details.'), t("Index errors warning isn't displayed."));
-    $this->assertText(t("Couldn't index items. Check the logs for details."), t('Index error is displayed.'));
+    $this->assertNoPattern('/' . str_replace('144', '-?\d*', t('Successfully indexed @count items.', array('@count' => 144))) . '/', 'No items could be indexed.');
+    $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);
@@ -327,20 +328,20 @@ class SearchApiWebTest extends DrupalWebTestCase {
     );
     $this->drupalPost(NULL, $values, t('Index now'));
     $this->assertText(t('Successfully indexed @count items.', array('@count' => 3)));
-    $this->assertNoText(t("Some items couldn't be indexed. Check the logs for details."), t("Index errors warning isn't displayed."));
-    $this->assertNoText(t("Couldn't index items. Check the logs for details."), t("Index error isn't displayed."));
-    $this->assertText(t('All items have been indexed (@indexed / @total).', array('@indexed' => 10, '@total' => 10)), t('!field displayed.', array('!field' => t('Correct index status'))));
-    $this->assertNoText(t('Index now'), t('"Index now" button no longer displayed.'));
+    $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.');
   }
 
   protected function searchSuccess() {
     $this->drupalGet('search_api_test/query/' . $this->index_id);
-    $this->assertText('result count = 10', t('Correct search result count returned after indexing.'));
-    $this->assertText('results = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)', t('Correct search results returned after indexing.'));
+    $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.');
 
     $this->drupalGet('search_api_test/query/' . $this->index_id . '/foo/2/4');
-    $this->assertText('result count = 10', t('Correct search result count with ranged query.'));
-    $this->assertText('results = (3, 4, 5, 6)', t('Correct search results with ranged query.'));
+    $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.');
   }
 
   protected function editServer() {
@@ -351,22 +352,40 @@ class SearchApiWebTest extends DrupalWebTestCase {
     );
     $this->drupalPost("admin/config/search/search_api/server/{$this->server_id}/edit", $values, t('Save settings'));
     $this->assertText(t('The search server was successfully edited.'));
-    $this->assertText('test-name-foo', t('!field changed.', array('!field' => t('Name'))));
-    $this->assertText('test-description-bar', t('!field changed.', array('!field' => t('Description'))));
-    $this->assertText('test-test-baz', t('!field changed.', array('!field' => t('Service options'))));
+    $this->assertText('test-name-foo', 'Name changed.');
+    $this->assertText('test-description-bar', 'Description changed.');
+    $this->assertText('test-test-baz', 'Service options changed.');
   }
 
   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)), t('!field displayed.', array('!field' => t('Correct index status'))));
+    $this->assertText(t('All items still need to be indexed (@total total).', array('@total' => 10)), 'Correct index status displayed.');
   }
 
   protected function deleteServer() {
     $this->drupalPost("admin/config/search/search_api/server/{$this->server_id}/delete", array(), t('Confirm'));
-    $this->assertNoText('test-name-foo', t('Server no longer listed.'));
+    $this->assertNoText('test-name-foo', 'Server no longer listed.');
     $this->drupalGet("admin/config/search/search_api/index/{$this->index_id}/status");
-    $this->assertText(t('The index is currently disabled.'), t('The index was disabled and removed from the server.'));
+    $this->assertText(t('The index is currently disabled.'), 'The index was disabled and removed from the server.');
+  }
+
+  protected function disableModules() {
+    module_disable(array('search_api_test'), FALSE);
+    $this->assertFalse(module_exists('search_api_test'), 'Test module was successfully disabled.');
+    module_disable(array('search_api'), FALSE);
+    $this->assertFalse(module_exists('search_api'), 'Search API module was successfully disabled.');
+
+    drupal_uninstall_modules(array('search_api_test'), FALSE);
+    $this->assertEqual(drupal_get_installed_schema_version('search_api_test', TRUE), SCHEMA_UNINSTALLED, 'Test module was successfully uninstalled.');
+    $this->assertFalse(db_table_exists('search_api_test'), 'Test module table was successfully removed.');
+    drupal_uninstall_modules(array('search_api'), FALSE);
+    $this->assertEqual(drupal_get_installed_schema_version('search_api', TRUE), SCHEMA_UNINSTALLED, 'Search API module was successfully uninstalled.');
+    $this->assertFalse(db_table_exists('search_api_server'), 'Search server table was successfully removed.');
+    $this->assertFalse(db_table_exists('search_api_index'), 'Search index table was successfully removed.');
+    $this->assertFalse(db_table_exists('search_api_item'), 'Index items table was successfully removed.');
+    $this->assertNull(variable_get('search_api_tasks'), 'Tasks variable was correctly removed.');
+    $this->assertNull(variable_get('search_api_index_worker_callback_runtime'), 'Worker runtime variable was correctly removed.');
   }
 
 }
@@ -446,41 +465,38 @@ class SearchApiUnitTest extends DrupalWebTestCase {
   public function checkQueryParseKeys() {
     $options['parse mode'] = 'direct';
     $mode = &$options['parse mode'];
-    $num = 1;
     $query = new SearchApiQuery($this->index, $options);
     $modes = $query->parseModes();
 
     $query->keys('foo');
-    $this->assertEqual($query->getKeys(), 'foo', t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    $this->assertEqual($query->getKeys(), 'foo', '"Direct query" parse mode, test 1.');
     $query->keys('foo bar');
-    $this->assertEqual($query->getKeys(), 'foo bar', t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    $this->assertEqual($query->getKeys(), 'foo bar', '"Direct query" parse mode, test 2.');
     $query->keys('(foo bar) OR "bar baz"');
-    $this->assertEqual($query->getKeys(), '(foo bar) OR "bar baz"', t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    $this->assertEqual($query->getKeys(), '(foo bar) OR "bar baz"', '"Direct query" parse mode, test 3.');
 
     $mode = 'single';
-    $num = 1;
     $query = new SearchApiQuery($this->index, $options);
 
     $query->keys('foo');
-    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo'), '"Single term" parse mode, test 1.');
     $query->keys('foo bar');
-    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo bar'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo bar'), '"Single term" parse mode, test 2.');
     $query->keys('(foo bar) OR "bar baz"');
-    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', '(foo bar) OR "bar baz"'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', '(foo bar) OR "bar baz"'), '"Single term" parse mode, test 3.');
 
     $mode = 'terms';
-    $num = 1;
     $query = new SearchApiQuery($this->index, $options);
 
     $query->keys('foo');
-    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo'), '"Multiple terms" parse mode, test 1.');
     $query->keys('foo bar');
-    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo', 'bar'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'foo', 'bar'), '"Multiple terms" parse mode, test 2.');
     $query->keys('(foo bar) OR "bar baz"');
-    $this->assertEqual($query->getKeys(), array('(foo', 'bar)', 'OR', 'bar baz', '#conjunction' => 'AND'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    $this->assertEqual($query->getKeys(), array('(foo', 'bar)', 'OR', 'bar baz', '#conjunction' => 'AND'), '"Multiple terms" parse mode, test 3.');
     // http://drupal.org/node/1468678
     $query->keys('"Münster"');
-    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'Münster'), t('"@mode" parse mode, test !num.', array('@mode' => $modes[$mode]['name'], '!num' => $num++)));
+    $this->assertEqual($query->getKeys(), array('#conjunction' => 'AND', 'Münster'), '"Multiple terms" parse mode, test 4.');
   }
 
   public function checkIgnoreCaseProcessor() {
@@ -524,30 +540,30 @@ class SearchApiUnitTest extends DrupalWebTestCase {
     $processor = new SearchApiIgnoreCase($this->index, array('fields' => array('name' => 'name')));
     $tmp = $items;
     $processor->preprocessIndexItems($tmp);
-    $this->assertEqual($tmp[1]['name']['value'], $processed, t('!type field was processed.', array('!type' => 'name')));
-    $this->assertEqual($tmp[1]['mail']['value'], $orig, t("!type field wasn't processed.", array('!type' => 'mail')));
+    $this->assertEqual($tmp[1]['name']['value'], $processed, 'Name field was processed.');
+    $this->assertEqual($tmp[1]['mail']['value'], $orig, "Mail field wasn't processed.");
 
     $query = new SearchApiQuery($this->index);
     $query->keys('Foo "baR BaZ" fOObAr1');
     $query->condition('name', 'FOO');
     $query->condition('mail', 'BAR');
     $processor->preprocessSearchQuery($query);
-    $this->assertEqual($query->getKeys(), $keys1, t('Search keys were processed correctly.'));
-    $this->assertEqual($query->getFilter()->getFilters(), $filters1, t('Filters were processed correctly.'));
+    $this->assertEqual($query->getKeys(), $keys1, 'Search keys were processed correctly.');
+    $this->assertEqual($query->getFilter()->getFilters(), $filters1, 'Filters were processed correctly.');
 
     $processor = new SearchApiIgnoreCase($this->index, array('fields' => array('name' => 'name', 'mail' => 'mail')));
     $tmp = $items;
     $processor->preprocessIndexItems($tmp);
-    $this->assertEqual($tmp[1]['name']['value'], $processed, t('!type field was processed.', array('!type' => 'name')));
-    $this->assertEqual($tmp[1]['mail']['value'], $processed, t('!type field was processed.', array('!type' => 'mail')));
+    $this->assertEqual($tmp[1]['name']['value'], $processed, 'Name field was processed.');
+    $this->assertEqual($tmp[1]['mail']['value'], $processed, 'Mail field was processed.');
 
     $query = new SearchApiQuery($this->index);
     $query->keys('Foo "baR BaZ" fOObAr1');
     $query->condition('name', 'FOO');
     $query->condition('mail', 'BAR');
     $processor->preprocessSearchQuery($query);
-    $this->assertEqual($query->getKeys(), $keys2, t('Search keys were processed correctly.'));
-    $this->assertEqual($query->getFilter()->getFilters(), $filters2, t('Filters were processed correctly.'));
+    $this->assertEqual($query->getKeys(), $keys2, 'Search keys were processed correctly.');
+    $this->assertEqual($query->getFilter()->getFilters(), $filters2, 'Filters were processed correctly.');
   }
 
   public function checkTokenizer() {
@@ -614,22 +630,22 @@ class SearchApiUnitTest extends DrupalWebTestCase {
     $processor = new SearchApiTokenizer($this->index, array('fields' => array('name' => 'name'), 'spaces' => '[^\p{L}\p{N}]', 'ignorable' => '[-]'));
     $tmp = $items;
     $processor->preprocessIndexItems($tmp);
-    $this->assertEqual($tmp[1]['name']['value'], $processed1, t('Value was correctly tokenized with default settings.'));
+    $this->assertEqual($tmp[1]['name']['value'], $processed1, 'Value was correctly tokenized with default settings.');
 
     $query = new SearchApiQuery($this->index, array('parse mode' => 'direct'));
     $query->keys("foo \"bar-baz\" \n\t foobar1");
     $processor->preprocessSearchQuery($query);
-    $this->assertEqual($query->getKeys(), 'foo barbaz foobar1', t('Search keys were processed correctly.'));
+    $this->assertEqual($query->getKeys(), 'foo barbaz foobar1', 'Search keys were processed correctly.');
 
     $processor = new SearchApiTokenizer($this->index, array('fields' => array('name' => 'name'), 'spaces' => '[-a]', 'ignorable' => '\s'));
     $tmp = $items;
     $processor->preprocessIndexItems($tmp);
-    $this->assertEqual($tmp[1]['name']['value'], $processed2, t('Value was correctly tokenized with custom settings.'));
+    $this->assertEqual($tmp[1]['name']['value'], $processed2, 'Value was correctly tokenized with custom settings.');
 
     $query = new SearchApiQuery($this->index, array('parse mode' => 'direct'));
     $query->keys("foo \"bar-baz\" \n\t foobar1");
     $processor->preprocessSearchQuery($query);
-    $this->assertEqual($query->getKeys(), 'foo"b r b z"foob r1', t('Search keys were processed correctly.'));
+    $this->assertEqual($query->getKeys(), 'foo"b r b z"foob r1', 'Search keys were processed correctly.');
   }
 
   public function checkHtmlFilter() {
@@ -694,7 +710,7 @@ END;
     $processor->preprocessIndexItems($tmp);
     $processor = new SearchApiTokenizer($this->index, array('fields' => array('name' => 'name'), 'spaces' => '[\s.:]', 'ignorable' => ''));
     $processor->preprocessIndexItems($tmp);
-    $this->assertEqual($tmp[1]['name']['value'], $processed1, t('Text was correctly processed.'));
+    $this->assertEqual($tmp[1]['name']['value'], $processed1, 'Text was correctly processed.');
   }
 
 }

+ 3 - 3
tests/search_api_test.info

@@ -10,9 +10,9 @@ files[] = search_api_test.module
 
 hidden = TRUE
 
-; Information added by drupal.org packaging script on 2013-01-09
-version = "7.x-1.4"
+; Information added by drupal.org packaging script on 2013-09-01
+version = "7.x-1.8"
 core = "7.x"
 project = "search_api"
-datestamp = "1357726719"
+datestamp = "1378025826"
 

+ 8 - 2
tests/search_api_test.install

@@ -22,7 +22,7 @@ function search_api_test_schema() {
         'description' => 'The title of the item.',
         'type' => 'varchar',
         'length' => 50,
-        'not null' => TRUE,
+        'not null' => FALSE,
       ),
       'body' => array(
         'description' => 'A text belonging to the item.',
@@ -33,7 +33,13 @@ function search_api_test_schema() {
         'description' => 'A string identifying the type of item.',
         'type' => 'varchar',
         'length' => 50,
-        'not null' => TRUE,
+        'not null' => FALSE,
+      ),
+      'keywords' => array(
+        'description' => 'A comma separated list of keywords.',
+        'type' => 'varchar',
+        'length' => 200,
+       'not null' => FALSE,
       ),
     ),
     'primary key' => array('id'),

+ 25 - 19
tests/search_api_test.module

@@ -43,6 +43,9 @@ function search_api_test_insert_item(array $form, array &$form_state) {
     'type' => array(
       '#type' => 'textfield',
     ),
+    'keywords' => array(
+      '#type' => 'textfield',
+    ),
     'submit' => array(
       '#type' => 'submit',
       '#value' => t('Save'),
@@ -55,7 +58,7 @@ function search_api_test_insert_item(array $form, array &$form_state) {
  */
 function search_api_test_insert_item_submit(array $form, array &$form_state) {
   form_state_values_clean($form_state);
-  db_insert('search_api_test')->fields($form_state['values'])->execute();
+  db_insert('search_api_test')->fields(array_filter($form_state['values']))->execute();
   module_invoke_all('entity_insert', search_api_test_load($form_state['values']['id']), 'search_api_test');
 }
 
@@ -78,25 +81,8 @@ function search_api_test_view($entity) {
  * Menu callback for executing a search.
  */
 function search_api_test_query(SearchApiIndex $index, $keys = 'foo bar', $offset = 0, $limit = 10, $fields = NULL, $sort = NULL, $filters = NULL) {
-  // Slight "hack" for testing complex queries.
-  if ($keys == '|COMPLEX|') {
-    $keys = array(
-      '#conjunction' => 'AND',
-      'test',
-      array(
-        '#conjunction' => 'OR',
-        'baz',
-        'foobar',
-      ),
-      array(
-        '#conjunction' => 'AND',
-        '#negation' => TRUE,
-        'bar',
-      ),
-    );
-  }
   $query = $index->query()
-    ->keys($keys)
+    ->keys($keys ? $keys : NULL)
     ->range($offset, $limit);
   if ($fields) {
     $query->fields(explode(',', $fields));
@@ -177,6 +163,12 @@ function search_api_test_entity_property_info() {
       'description' => "The item's parent.",
       'getter callback' => 'search_api_test_parent',
     ),
+    'keywords' => array(
+      'label' => 'Keywords',
+      'type' => 'list<string>',
+      'description' => 'An optional collection of keywords describing the item.',
+      'getter callback' => 'search_api_test_list_callback',
+    ),
   );
 
   return $info;
@@ -198,6 +190,20 @@ function search_api_test_parent($entity) {
   return search_api_test_load($entity->id - 1);
 }
 
+/**
+ * List callback.
+ */
+function search_api_test_list_callback($data) {
+  //return is_array($entity->keywords) ? $entity->keywords : explode(',', $entity->keywords);
+  if (is_array($data)) {
+    $res = is_array($data['keywords']) ? $data['keywords'] : explode(',', $data['keywords']);
+  }
+  else {
+    $res = is_array($data->keywords) ? $data->keywords : explode(',', $data->keywords);
+  }
+  return array_filter($res);
+}
+
 /**
  * Implements hook_search_api_service_info().
  */