From ddd486265b67d6842cf8f67dac005e18c813a20c Mon Sep 17 00:00:00 2001
From: Katherine Bailey <katherine@katbailey.net>
Date: Fri, 17 Jun 2011 14:22:12 -0700
Subject: [PATCH] Issue #1182614: Facetapi integration

---
 plugins/facetapi/adapter.inc         |  186 ++++++++++++++++++++++++++++++
 plugins/facetapi/query_type_date.inc |  157 ++++++++++++++++++++++++++
 plugins/facetapi/query_type_term.inc |   96 ++++++++++++++++
 search_api.info                      |    3 +
 search_api.module                    |  205 ++++++++++++++++++++++++++++++++++
 5 files changed, 647 insertions(+), 0 deletions(-)
 create mode 100644 plugins/facetapi/adapter.inc
 create mode 100644 plugins/facetapi/query_type_date.inc
 create mode 100644 plugins/facetapi/query_type_term.inc

diff --git plugins/facetapi/adapter.inc plugins/facetapi/adapter.inc
new file mode 100644
index 0000000..0e0552d
--- /dev/null
+++ plugins/facetapi/adapter.inc
@@ -0,0 +1,186 @@
+<?php
+
+/**
+ * @file
+ * Classes used by the Facet API module.
+ */
+
+/**
+ * Facet API adapter for the Search API module.
+ */
+class SearchApiFacetapiAdapter extends FacetapiAdapter {
+  /**
+   * Returns the path to the admin settings for a given realm.
+   *
+   * @param $realm_name
+   *   The name of the realm.
+   *
+   * @return
+   *   The path to the admin settings.
+   */
+  public function getPath($realm_name) {
+    $base_path = 'admin/config/search/search_api';
+    $index = $this->info['instance'];
+    return $base_path . '/index/'. $index .'/facets/' . $realm_name;
+  }
+
+  /**
+   * Allows the backend to initialize its query object before adding the facet
+   * filters.
+   *
+   * @param mixed $query
+   *   The backend's native object.
+   */
+  function initActiveFilters($query) {
+    $search_id = $query->getOption('search id');
+    $facets = facetapi_get_enabled_facets($this->info['instance']);
+    $fields = array();
+
+    // Looked to search_api_facets_search_api_query_alter() for guidance here.
+    $active = &drupal_static('search_api_facetapi_active_facets', array());
+    foreach ($facets as $facet) {
+      $options = $this->getFacet($facet)->getSettings()->settings;
+      // The 'default_true' option is a choice between "show on all but the
+      // selected searches" (TRUE) and "show for only the selected searches"
+      $default_true = isset($options['default_true']) ? $options['default_true'] : TRUE;
+      // The 'facet_search_ids' option is the list of selected searches that will
+      // either be excluded or for which the facet will exclusively be displayed.
+      $facet_search_ids = isset($options['facet_search_ids']) ? $options['facet_search_ids'] : array();
+
+      if (array_search($search_id, $facet_search_ids) === FALSE) {
+        $search_ids = variable_get('search_api_facets_search_ids', array());
+        if (empty($search_ids[$search_id])) {
+          // Remember this search ID.
+          $search_ids[$search_id] = $search_id;
+          variable_set('search_api_facets_search_ids', $search_ids);
+        }
+        if (!$default_true) {
+          continue; // We are only to show facets for explicitly named search ids.
+        }
+      }
+      elseif ($default_true) {
+        continue; // The 'facet_search_ids' in the settings are to be excluded.
+      }
+      $active[$facet['name']] = $search_id;
+      $fields[$facet['name']] = array(
+        'field'     => $facet['field'],
+        // These were copied from the default options specified in the constructor
+        // for SearchApiFacet in search_api_facet.entity.inc.
+        // TODO: I suspect some of them are now irrelevant and should be removed.
+        'limit'             => 10,
+        'display_more_link' => FALSE,
+        'more_limit'        => 10,
+        'min_count'         => 1,
+        'sort'              => 'count',
+        'missing'           => $facet['facet missing allowed'],
+        'show_active'       => TRUE,
+        'default_true'      => TRUE,
+        'ids_list'          => array(),
+        'type'              => '',
+      );
+    }
+
+    if ($fields) {
+      $old = $query->setOption('search_api_facets', $fields);
+      if ($old) { // This will only happen if other modules add facets of their own.
+        $query->setOption('search_api_facets', $fields + $old);
+      }
+    }
+  }
+
+  /**
+   * Returns a boolean flagging whether $this->_searcher executed a search.
+   */
+  public function searchExecuted() {
+    // See the comment in the getSearchKeys() method - we shouldn't just be
+    // checking if search api executed a search, but whether it executed a search
+    // for the particular search that we want a current search block and bread-
+    // crumbs for. However, there is currently no way of mapping this.
+    $searches = search_api_current_search();
+    return (bool)count($searches);
+  }
+
+  public function suppressOutput($realm_name) {
+    // Not sure under what circumstances the output will need to be suppressed,
+    // hand off to the search backend module...?
+    //return module_invoke($this->info['module'], 'search_api_suppress_output');
+    return FALSE;
+  }
+
+  /**
+   * Returns the search keys.
+   */
+  public function getSearchKeys() {
+
+    $searches = search_api_current_search();
+    // 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 cannot
+    // match it up to the correct "current search"
+    // I suspect that http://drupal.org/node/593658 would help.
+    // For now, just taking the first current search :-/
+    $first = key($searches);
+    $query = $searches[$first][0];
+    $keys = $query->getOriginalKeys();
+    if (is_array($keys)) {
+      $keys = implode(' ', $keys);
+    }
+    return $keys;
+  }
+
+  /**
+   * Returns the number of total results found for the current search.
+   */
+  public function getResultCount() {
+
+    // See above for why we're just using the first current search.
+    $searches = search_api_current_search();
+    $first = key($searches);
+    // Each search is an array with the query as the first element and the results
+    // array as the second.
+    if (isset($searches[$first][1])) {
+      return $searches[$first][1]['result count'];
+    }
+    return 0;
+  }
+
+  /**
+   * Allows for backend specific overrides to the settings form.
+   */
+  public function settingsForm(&$form, &$form_state) {
+    $search_ids = variable_get('search_api_facets_search_ids', array());
+    $facet = $form['#facetapi']['facet'];
+    $realm = $form['#facetapi']['realm'];
+    $facet_settings = $this->getFacet($facet)->getSettings();
+    $options = $facet_settings->settings;
+    $search_ids = variable_get('search_api_facets_search_ids', array());
+    if (count($search_ids) > 1) {
+      $form['global']['default_true'] = array(
+        '#type' => 'select',
+        '#title' => t('Display for searches'),
+        '#options' => array(
+          TRUE => t('For all except the selected'),
+          FALSE => t('Only for the selected'),
+        ),
+        '#default_value' => isset($options['default_true']) ? $options['default_true'] : TRUE,
+      );
+      $form['global']['facet_search_ids'] = array(
+        '#type' => 'select',
+        '#title' => t('Search IDs'),
+        '#options' => $search_ids,
+        '#size' => min(4, count($search_ids)),
+        '#multiple' => TRUE,
+        '#default_value' => isset($options['facet_search_ids']) ? $options['facet_search_ids'] : array(),
+      );
+    }
+    else {
+      $form['global']['default_true'] = array(
+        '#type' => 'value',
+        '#value' => TRUE,
+      );
+      $form['global']['facet_search_ids'] = array(
+        '#type' => 'value',
+        '#value' => array(),
+      );
+    }
+  }
+}
diff --git plugins/facetapi/query_type_date.inc plugins/facetapi/query_type_date.inc
new file mode 100644
index 0000000..9c1b3ff
--- /dev/null
+++ plugins/facetapi/query_type_date.inc
@@ -0,0 +1,157 @@
+<?php
+
+/**
+ * @file
+ * Date query type plugin for the Search API adapter.
+ */
+
+/**
+ * Plugin for "date" query types.
+ */
+class SearchApiFacetapiDate extends FacetapiQueryTypeDate implements FacetapiQueryTypeInterface {
+
+  /**
+   * Returns the query type associated with the plugin.
+   *
+   * @return string
+   *   The query type.
+   */
+  static public function getType() {
+    return 'date';
+  }
+
+  /**
+   * Helper function that adds facet filters to a query.
+   */
+  public function addFacetFilters($query) {
+    if ($active = $this->adapter->getActiveItems($this->facet)) {
+      $item = end($active);
+      $field = $this->facet['field'];
+      $query->condition($field, strtotime($item['start']), '>=');
+      $query->condition($field, strtotime($item['end']), '<');
+    }
+  }
+
+  /**
+   * Adds the filter to the query object.
+   *
+   * @param $query
+   *   An object containing the query in the backend's native API.
+   */
+  public function execute($query) {
+    $this->addFacetFilters($query);
+  }
+
+
+  /**
+   * Initializes the facet's build array.
+   *
+   * @return array
+   *   The initialized render array.
+   */
+  public function build() {
+
+    $facet = $this->adapter->getFacet($this->facet);
+    $search_ids = drupal_static('search_api_facetapi_active_facets', array());
+    if (empty($search_ids[$facet['name']]) || !search_api_current_search($search_ids[$facet['name']])) {
+      return array();
+    }
+    $search_id = $search_ids[$facet['name']];
+    $build = array();
+    $search = search_api_current_search($search_id);
+    $results = $search[1];
+    if (!$results['result count']) {
+      return array();
+    }
+    // Gets total number of documents matched in search.
+    $total = $results['result count'];
+
+    // Most of the code below is copied from search_facetapi's implementation of
+    // 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']];
+      foreach ($values as $value) {
+        if ($value['count']) {
+          $filter = substr($value['filter'], 1, -1);
+          $raw_values[$filter] = $value['count'];
+        }
+      }
+    }
+
+    // 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, mintue being the lowest be can go.
+      $date_gap = facetapi_get_date_gap($item['start'], $item['end']);
+      $gap = facetapi_get_next_date_gap($date_gap, FACETAPI_DATE_MINUTE);
+
+      // If there is a previous item, there is a parent, uses a reference so the
+      // arrays are populated when they are updated.
+      if (NULL !== $parent) {
+        $build[$parent]['#item_children'][$value] = &$build[$value];
+        $build[$value]['#item_parents'][$parent] = $parent;
+      }
+
+      // Stores the last value iterated over.
+      $parent = $value;
+    }
+    if (empty($raw_values)) {
+      return $build;
+    }
+    ksort($raw_values);
+
+    // Mind the gap! Calculates gap from min and max timestamps.
+    $timestamps = array_keys($raw_values);
+    if (NULL === $parent) {
+      if (count($raw_values) > 1) {
+        $gap = facetapi_get_timestamp_gap(min($timestamps), max($timestamps));
+      }
+      else {
+        $gap = FACETAPI_DATE_HOUR;
+      }
+    }
+
+    // Converts all timestamps to dates in ISO 8601 format.
+    $dates = array();
+    foreach ($timestamps as $timestamp) {
+      $dates[$timestamp] = facetapi_isodate($timestamp, $gap);
+    }
+
+    // Treat each date as the range start and next date as the range end.
+    $range_end = array();
+    $previous = NULL;
+    foreach (array_unique($dates) as $date) {
+      if (NULL !== $previous) {
+        $range_end[$previous] = facetapi_get_next_date_increment($previous, $gap);
+      }
+      $previous = $date;
+    }
+    $range_end[$previous] = facetapi_get_next_date_increment($previous, $gap);
+
+    // Groups dates by the range they belong to, builds the $build array
+    // with the facet counts and formatted range values.
+    foreach ($raw_values as $value => $count) {
+      $new_value = '[' . $dates[$value] . ' TO ' . $range_end[$dates[$value]] . ']';
+      if (!isset($build[$new_value])) {
+        $build[$new_value] = array('#count' => $count);
+      }
+      else {
+        $build[$new_value]['#count'] += $count;
+      }
+
+      // Adds parent information if not already set.
+      if (NULL !== $parent) {
+        $build[$parent]['#item_children'][$new_value] = &$build[$new_value];
+        $build[$new_value]['#item_parents'][$parent] = $parent;
+      }
+    }
+
+    return $build;
+
+  }
+}
diff --git plugins/facetapi/query_type_term.inc plugins/facetapi/query_type_term.inc
new file mode 100644
index 0000000..f57e218
--- /dev/null
+++ plugins/facetapi/query_type_term.inc
@@ -0,0 +1,96 @@
+<?php
+
+/**
+ * @file
+ * Term query type plugin for the Apache Solr adapter.
+ */
+
+/**
+ * Plugin for "term" query types.
+ */
+class SearchApiFacetapiTerm extends FacetapiQueryType implements FacetapiQueryTypeInterface {
+
+  /**
+   * Returns the query type associated with the plugin.
+   *
+   * @return string
+   *   The query type.
+   */
+  static public function getType() {
+    return 'term';
+  }
+
+  /**
+   * Adds the filter to the query object.
+   *
+   * @param SearchApiQueryInterface $query
+   *   An object containing the query in the backend's native API.
+   */
+  public function execute($query) {
+    $settings = $this->adapter->getFacet($this->facet)->getSettings();
+    // Adds the operator parameter.
+    $operator = $settings->settings['operator'];
+
+    // Add active facet filters.
+    $active = $this->adapter->getActiveItems($this->facet);
+    if (empty($active)) {
+      return;
+    }
+
+    // Adds filter based on the operator.
+    if (FACETAPI_OPERATOR_OR != $operator) {
+      foreach ($active as $filter => $filter_array) {
+        if ($filter_array['value'] == '!') {
+          $query->condition($filter_array['field alias'], NULL);
+        }
+        else {
+          $query->condition($filter_array['field alias'], $filter_array['value']);
+        }
+      }
+    }
+    else {
+      // OR facet.
+      $facet_filter = $query->createFilter('OR');
+      foreach ($active as $filter => $filter_array) {
+        if ($filter_array['value'] == '!') {
+          $facet_filter->condition($filter_array['field alias'], NULL);
+        }
+        else {
+          $facet_filter->condition($filter_array['field alias'], $filter_array['value']);
+        }
+      }
+      $query->filter($facet_filter);
+    }
+  }
+
+  /**
+   * Initializes the facet's build array.
+   *
+   * @return array
+   *   The initialized render array.
+   */
+  public function build() {
+    $facet = $this->adapter->getFacet($this->facet);
+    // Copying the mechanism used by search_api_facets to store the current search
+    // per facet in a static var (during initActiveFilters) so that we can retrieve
+    // it here and get the correct current search for this facet.
+    $search_ids = drupal_static('search_api_facetapi_active_facets', array());
+    if (empty($search_ids[$facet['name']]) || !search_api_current_search($search_ids[$facet['name']])) {
+      return array();
+    }
+    $search_id = $search_ids[$facet['name']];
+    $build = array();
+    $search = search_api_current_search($search_id);
+    $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']];
+      foreach ($values as $value) {
+        if ($value['count']) {
+          $filter = trim($value['filter'], '"');
+          $build[$filter] = array('#count' => $value['count']);
+        }
+      }
+    }
+    return $build;
+  }
+}
diff --git search_api.info search_api.info
index 440825b..d501b7a 100644
--- search_api.info
+++ search_api.info
@@ -21,5 +21,8 @@ files[] = includes/processor_tokenizer.inc
 files[] = includes/query.inc
 files[] = includes/server_entity.inc
 files[] = includes/service.inc
+files[] = plugins/facetapi/adapter.inc
+files[] = plugins/facetapi/query_type_term.inc
+files[] = plugins/facetapi/query_type_date.inc
 
 configure = admin/config/search/search_api
diff --git search_api.module search_api.module
index 5ca8922..0c5e22e 100644
--- search_api.module
+++ search_api.module
@@ -149,6 +149,47 @@ function search_api_menu() {
     'file' => 'search_api.admin.inc',
     'type' => MENU_LOCAL_TASK,
   );
+  // Following apachesolr module's lead here. We need to handle our own menu paths
+  // for facets because we need a facet configuration page per index.
+  if (module_exists('facetapi')) {
+    $file_path = drupal_get_path('module', 'facetapi');
+    $first = TRUE;
+    foreach (facetapi_get_realm_info() as $realm_name => $realm) {
+      if ($first) {
+        $first = FALSE;
+        $items[$pre . '/index/%/facets'] = array(
+          'title'            => 'Facets',
+          'page callback'    => 'drupal_get_form',
+          'page arguments'   =>  array('facetapi_realm_settings_form', 5, $realm_name),
+          'tab_parent'       => $pre . '/index/%',
+          'weight'           => -7,
+          'access arguments' => array('administer search'),
+          'file path'        => $file_path,
+          'file'             => 'facetapi.admin.inc',
+          'type'             => MENU_LOCAL_TASK,
+          'context'          => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE,
+        );
+        $items[$pre . '/index/%/facets/' . $realm_name] = array(
+          'title'            => $realm['label'],
+          'type'             => MENU_DEFAULT_LOCAL_TASK,
+          'weight'           => $realm['weight'],
+        );
+      }
+      else {
+        $items[$pre . '/index/%/facets/' . $realm_name] = array(
+          'title'            => $realm['label'],
+          'page callback'    => 'drupal_get_form',
+          'page arguments'   => array('facetapi_realm_settings_form', 5, $realm_name),
+          'tab_parent'       => $pre . '/index/%',
+          'access arguments' => array('administer search'),
+          'type'             => MENU_LOCAL_TASK,
+          'context'          => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE,
+          'file path'        => $file_path,
+          'file'             => 'facetapi.admin.inc',
+        );
+      }
+    }
+  }
 
   return $items;
 }
@@ -707,6 +748,141 @@ function search_api_search_api_processor_info() {
 }
 
 /**
+ * Implements hook_facetapi_searcher_info().
+ */
+function search_api_facetapi_searcher_info() {
+  $info = array();
+  $indexes = search_api_index_load_multiple(FALSE);
+  foreach ($indexes as $index) {
+    if ($index->enabled) {
+      $info[$index->machine_name] = array(
+        'label' => t('Search service: @name', array('@name' => $index->name)),
+        'adapter' => 'search_api',
+        'instance' => $index->machine_name,
+        'type' => $index->entity_type,
+        'path' => '',
+      );
+    }
+  }
+  return $info;
+}
+
+/**
+ * Implements hook_facetapi_facet_info().
+ */
+function search_api_facetapi_facet_info($searcher_info) {
+  $facet_info = array();
+  if ('search_api' == $searcher_info['adapter']) {
+    // We store our facet info statically so that we can then make sure that
+    // only the facets created here are available as facets for this index.
+    // See search_api_facetapi_facet_info_alter() below.
+    $facets = &drupal_static(__FUNCTION__, array());
+    if (!empty($facets[$searcher_info['instance']])) {
+      return $facets[$searcher_info['instance']];
+    }
+    $index = search_api_index_load($searcher_info['instance']);
+    if ($disabled = empty($index->enabled)) {
+      drupal_set_message('Since this index is at the moment disabled, no facets can be activated.', 'warning');
+      return array();
+    }
+    if (!empty($index->options['fields'])) {
+      $types = search_api_field_types();
+      // We use facetapi's 'map callback' option to swap out the indexed value
+      // of a field (which might be an integer, e.g. a tid) for a proper facet
+      // label (e.g. the term name). This only affects fields with an 'entity_type'
+      // property, and for the most part we'll use a generic callback for all
+      // entity types. This array lists the callbacks used for special entity
+      // types, e.g. 'taxonomy_term', for which facetapi has already provded a
+      // map callback, as well as the generic callback for everything else.
+      $field_map_callbacks = array(
+        'taxonomy_term' => 'facetapi_map_taxonomy_terms',
+        'entity' => 'search_api_map_entities',
+      );
+      foreach ($index->options['fields'] as $key => $field) {
+        if (!$field['indexed']) {
+          continue;
+        }
+        $map_type = $mapy_options = NULL;
+        if (isset($field['entity_type'])) {
+          $map_type = $field['entity_type'];
+          if (!isset($field_map_callbacks[$field['entity_type']])) {
+            $map_type = 'entity';
+            // Our entity map callback will need to know more about the field,
+            // in particular, what entity_type it is.
+            $map_options = $field;
+          }
+        }
+        $facet_info[$key] = array(
+          'label' => t('!field', array('!field' => $field['name'])),
+          'field api name' => $field['name'],
+          'description' => t('Filter by @type.', array('@type' => $field['name'])),
+          'map callback' => isset($map_type) ? $field_map_callbacks[$map_type] : '',
+          'map options' => isset($map_options) ? $map_options : array(),
+          'allowed operators' => array(FACETAPI_OPERATOR_AND => TRUE),
+        );
+        if ($field['type'] == 'date') {
+          $facet_info[$key] = array_merge($facet_info[$key], array(
+            'map callback' => 'facetapi_map_date',
+            'query type' => 'date',
+          ));
+        }
+      }
+    }
+    $facets[$searcher_info['instance']] = $facet_info;
+  }
+  return $facet_info;
+}
+
+/**
+ * Implements hook_facetapi_facet_info_alter().
+ */
+function search_api_facetapi_facet_info_alter(&$facet_info, $searcher) {
+  if ($searcher['adapter'] == 'search_api') {
+    $facets = drupal_static('search_api_facetapi_facet_info');
+    // Unset any facets that were not created by Search API because they will
+    // not be in the index.
+    foreach ($facet_info as $name => $info) {
+      if (!isset($facets[$searcher['instance']][$name])) {
+        unset($facet_info[$name]);
+      }
+    }
+  }
+}
+
+/**
+ * Implements hook_facetapi_adapters().
+ */
+function search_api_facetapi_adapters() {
+  return array(
+    'search_api' => array(
+      'handler' => array(
+        'class' => 'SearchApiFacetapiAdapter',
+      ),
+    ),
+  );
+}
+
+/**
+ * Implements hook_facetapi_query_types().
+ */
+function search_api_facetapi_query_types() {
+  return array(
+    'search_api_term' => array(
+      'handler' => array(
+        'class' => 'SearchApiFacetapiTerm',
+        'adapter' => 'search_api',
+      ),
+    ),
+    'search_api_date' => array(
+      'handler' => array(
+        'class' => 'SearchApiFacetapiDate',
+        'adapter' => 'search_api',
+      ),
+    ),
+  );
+}
+
+/**
  * Mark the entities with the specified IDs as "dirty", i.e., as needing to be reindexed.
  *
  * For indexes for which items should be indexed immediately, the items are
@@ -899,6 +1075,21 @@ function search_api_query($id, array $options = array()) {
 }
 
 /**
+ * Implements hook_search_api_query_alter().
+ */
+function search_api_search_api_query_alter($query) {
+  if (module_exists('facetapi')) {
+    $index = $query->getIndex();
+    // This is the main point of communication between the facet system and the
+    // search back-end - it makes the query respond to active facets.
+    $adapter = facetapi_adapter_load($index->machine_name);
+    if ($adapter) {
+      $adapter->addActiveFilters($query);
+    }
+  }
+}
+
+/**
  * Static store for the searches executed on the current page. Can either be
  * used to store an executed search, or to retrieve a previously stored
  * search.
@@ -1757,3 +1948,17 @@ function search_api_index_delete($id) {
   $index->delete();
   return TRUE;
 }
+
+/**
+ * Mapping callback for entity-based facets. Replaces the indexed value of an
+ * entity (its id) with its label for facet display.
+ */
+function search_api_map_entities($values, $options) {
+  $entities = entity_load($options['entity_type'], $values);
+  $map = array();
+  foreach($entities as $eid => $entity) {
+    $label = entity_label($options['entity_type'], $entity);
+    $map[$eid] = $label;
+  }
+  return $map;
+}
-- 
1.7.4.1

