From e615ba4e6c5d816e1c2a3d138701ee2fbbabec29 Mon Sep 17 00:00:00 2001 From: Katherine Bailey Date: Fri, 17 Jun 2011 14:22:12 -0700 Subject: [PATCH] Issue #1182614: Facetapi integration --- plugins/facetapi/adapter.inc | 180 ++++++++++++++++++++++++++++++ plugins/facetapi/query_type_date.inc | 160 ++++++++++++++++++++++++++ plugins/facetapi/query_type_term.inc | 103 +++++++++++++++++ search_api.info | 3 + search_api.module | 203 ++++++++++++++++++++++++++++++++++ 5 files changed, 649 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..abcfb91 --- /dev/null +++ plugins/facetapi/adapter.inc @@ -0,0 +1,180 @@ +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'], + // copying these from the default options specified in the constructor + // for SearchApiFacet in search_api_facet.entity.inc. + // TODO: Proper mapping between the settings as supplied by facet API and + // the settings as used by the searcher. + '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() { + $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(); + // 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() { + + // Again, I am clueless about the notion of having more than one current + // search and am just working with the first one. + $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..ea927b3 --- /dev/null +++ plugins/facetapi/query_type_date.inc @@ -0,0 +1,160 @@ +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 hte code below is copied from search_facetapi's implementation of + // this method. + + // Gets min and max values. + $range = array( + $this->facet['min callback']($this->facet), + $this->facet['max callback']($this->facet), + ); + + // 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 = trim($value['filter'], '"'); + $raw_values[$filter] = $value['count']; + } + } + } + ksort($raw_values); + + // 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; + } + + // 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..f6868b9 --- /dev/null +++ plugins/facetapi/query_type_term.inc @@ -0,0 +1,103 @@ +adapter->getFacet($this->facet)->getSettings(); + // Adds the operator parameter. + $operator = $settings->settings['operator']; + + // Add active facet filters. + $active = $this->adapter->getActiveItems($this->facet); + + + // Adds filter based on the operator. + if (FACETAPI_OPERATOR_OR != $operator) { + + foreach ($active as $filter => $filter_array) { + if ($operator == '!') { + $query->condition($filter_array['field alias'], NULL); + } + else { + $filter = trim($filter_array['value'], '"'); + if (strlen($filter)) { + $query->condition($filter_array['field alias'], $filter); + } + } + } + } + else { + // OR facet. + $facet_filter = $query->createFilter('OR'); + foreach ($active as $filter => $filter_array) { + if ($operator == '!') { + $facet_filter->condition($filter_array['field alias'], NULL); + } + else { + $filter = trim($filter_array['value'], '"'); + if (strlen($filter)) { + $facet_filter->condition($filter_array['field alias'], $filter); + } + } + $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..c576bfd 100644 --- search_api.module +++ search_api.module @@ -149,11 +149,70 @@ 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; + // It seems crazy to have to use our own menu callback here when ideally + // we would directly use drupal_get_form('facetapi_realm_settings_form'). + // The problem is we don't want to pass it a loaded Search API Index + // object, just the actual machine name. I'm not sure the menu system + // has a way of doing that, i.e. telling it not to replace the machine + // name with the loaded object for this particular path, but keeping it + // part of the same menu :-/ + $items[$pre . '/index/%search_api_index/facets'] = array( + 'title' => 'Facets', + 'page callback' => 'search_api_facet_settings', + 'page arguments' => array($realm_name, 5), + '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/%search_api_index/facets/' . $realm_name] = array( + 'title' => $realm['label'], + 'type' => MENU_DEFAULT_LOCAL_TASK, + 'weight' => $realm['weight'], + ); + } + else { + $items[$pre . '/index/%search_api_index/facets/' . $realm_name] = array( + 'title' => $realm['label'], + 'page callback' => 'search_api_facet_settings', + 'page arguments' => array($realm_name, 5), + '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; } /** + * Menu callback for facetapi settings page. + * + * See the explanation in the hook_menu implementation above for why this seems, + * lamentably, to be necessary. Hopefully I am just missing something painfully + * obvious :-P + */ +function search_api_facet_settings($realm_name, $search_api_index = NULL) { + return array( + 'settings_form' => drupal_get_form('facetapi_realm_settings_form', $search_api_index->machine_name, $realm_name), + ); +} + +/** * Implements hook_theme(). */ function search_api_theme() { @@ -707,6 +766,135 @@ 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, + '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']); + // The code below has been copied from search_api_facets_index_select (in + // search_api_facets.admin.inc) and stripped down to basics for what we need. + if (!empty($index->options['fields'])) { + if ($disabled = empty($index->enabled)) { + drupal_set_message('Since this index is at the moment disabled, no facets can be activated.', 'warning'); + } + $types = search_api_field_types(); + // TODO: figure out if there are other callback mappings needed for other + // entity types. These are what take care of replacing the indexed value + // (such as a tid) with the label (e.g. the term name) for facet display. + $field_map_callbacks = array( + 'taxonomy_term' => 'facetapi_map_taxonomy_terms', + ); + foreach ($index->options['fields'] as $key => $field) { + if (!$field['indexed']) { + continue; + } + // TODO: Figure out how to deal with related fields, e.g. "author:name" + // For now I'm skipping over them as they won't work. + if (strpos($key, ':')) { + continue; + } + if (empty($facets[$key])) { + $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($field['entity_type']) && isset($field_map_callbacks[$field['entity_type']])) ? $field_map_callbacks[$field['entity_type']] : '', + 'allowed operators' => array(FACETAPI_OPERATOR_AND => TRUE), + ); + if ($field['type'] == 'date') { + // Faceting on date fields is more complex and requires some extra set-up. + $facet_info[$key] = array_merge($facet_info[$key], array( + 'map callback' => 'facetapi_map_date', + 'min callback' => 'facetapi_get_min_date', + 'max callback' => 'facetapi_get_max_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 +1087,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. -- 1.7.4.1