Commit c681455f authored by Jonathan Hunt's avatar Jonathan Hunt

#26 Add Search API 7.x-1.26, Search API Solr 7.x-1.14, Search API Views 7.x-1.26.

parent 42dce96c
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Search facets
-------------
This module allows you to create facetted searches for any search executed via
the Search API, no matter if executed by a search page, a view or any other
module. The only thing you'll need is a search service class that supports the
"search_api_facets" feature. Currently, the "Database search" and "Solr search"
modules supports this.
This module is built on the Facet API [1], which is needed for this module to
work.
[1] http://drupal.org/project/facetapi
Information for site builders
-----------------------------
For creating a facetted search, you first need a search. Create or find some
page displaying Search API search results, either via a search page, a view or
by any other means. Now go to the configuration page for the index on which
this search is executed.
If the index lies on a server supporting facets (and if this module is enabled),
you'll notice a "Facets" tab. Click it and it will take you to the index' facet
configuration page. You'll see a table containing all indexed fields and options
for enabling and configuring facets for them.
For a detailed explanation of the available options, please refer to the Facet
API documentation.
- Creating facets via the URL
Facets can be added to a search (for which facets are activated) by passing
appropriate GET parameters in the URL. Assuming you have an indexed field with
the machine name "field_price", you can filter on it in the following ways:
- Filter for a specific value. For finding only results that have a price of
exactly 100, pass the following $options to url() or l():
$options['query']['f'][] = 'field_price:100';
Or manually append the following GET parameter to a URL:
?f[0]=field_price:100
- Search for values in a specified range. The following example will only return
items that have a price greater than or equal to 100 and lower than 500.
Code: $options['query']['f'][] = 'field_price:[100 TO 500]';
URL: ?f[0]=field_price%3A%5B100%20TO%20500%5D
- Search for values above a value. The next example will find results which have
a price greater than or equal to 100. The asterisk (*) stands for "unlimited",
meaning that there is no upper limit. Filtering for values lower than a
certain value works equivalently.
Code: $options['query']['f'][] = 'field_price:[100 TO *]';
URL: ?f[0]=field_price%3A%5B100%20TO%20%2A%5D
- Search for missing values. This example will filter out all items which have
any value at all in the price field, and will therefore only list items on
which this field was omitted. (This naturally only makes sense for fields
that aren't required.)
Code: $options['query']['f'][] = 'field_price:!';
URL: ?f[0]=field_price%3A%21
- Search for present values. The following example will only return items which
have the price field set (regardless of the actual value). You can see that it
is actually just a range filter with unlimited lower and upper bound.
Code: $options['query']['f'][] = 'field_price:[* TO *]';
URL: ?f[0]=field_price%3A%5B%2A%20TO%20%2A%5D
Note: When filtering a field whose machine name contains a colon (e.g.,
"author:roles"), you'll have to additionally URL-encode the field name in these
filter values:
Code: $options['query']['f'][] = rawurlencode('author:roles') . ':100';
URL: ?f[0]=author%253Aroles%3A100
- Issues
If you find any bugs or shortcomings while using this module, please file an
issue in the project's issue queue [1], using the "Facets" component.
[1] http://drupal.org/project/issues/search_api
Information for developers
--------------------------
- Features
If you are the developer of a SearchApiServiceInterface implementation and want
to support facets with your service class, too, you'll have to support the
"search_api_facets" feature. You can find details about the necessary additions
to your class in the example_servive.php file. In short, you'll just, when
executing a query, have to return facet terms and counts according to the
query's "search_api_facets" option, if present.
In order for the module to be able to tell that your server supports facets,
you will also have to change your service's supportsFeature() method to
something like the following:
public function supportsFeature($feature) {
return $feature == 'search_api_facets';
}
There is also a second feature defined by this module, namely
"search_api_facets_operator_or", for supporting "OR" facets. The requirements
for this feature are also explained in the example_servive.php file.
- Query option
The facets created follow the "search_api_base_path" option on the search query.
If set, this path will be used as the base path from which facet links will be
created. This can be used to show facets on pages without searches – e.g., as a
landing page.
- Hidden variable
The module uses one hidden variable, "search_api_facets_search_ids", to keep
track of the search IDs of searches executed for a given index. It is only
updated when a facet is displayed for the respective search, so isn't really a
reliable measure for this.
In any case, if you e.g. did some test searches and now don't want them to show
up in the block configuration forever after, just clear the variable:
variable_del("search_api_facets_search_ids")
<?php
/**
* @file
* Example implementation for a service class which supports facets.
*/
/**
* Example class explaining how facets can be supported by a service class.
*
* This class defines the "search_api_facets" and
* "search_api_facets_operator_or" features. Read the method documentation and
* inline comments in search() to learn how they can be supported by a service
* class.
*/
abstract class SearchApiFacetapiExampleService extends SearchApiAbstractService {
/**
* 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.
*
* If the service class supports facets, it should return TRUE if called with
* the feature name "search_api_facets". If it also supports "OR" facets, it
* should also return TRUE if called with "search_api_facets_operator_or".
*
* @param string $feature
* The name of the optional feature.
*
* @return boolean
* TRUE if this service knows and supports the specified feature. FALSE
* otherwise.
*/
public function supportsFeature($feature) {
$supported = array(
'search_api_facets' => TRUE,
'search_api_facets_operator_or' => TRUE,
);
return isset($supported[$feature]);
}
/**
* Executes a search on the server represented by this object.
*
* If the service class supports facets, it should check for an additional
* option on the query object:
* - search_api_facets: An array of facets to return along with the results
* for this query. The array is keyed by an arbitrary string which should
* serve as the facet's unique identifier for this search. The values are
* arrays with the following keys:
* - field: The field to construct facets for.
* - limit: The maximum number of facet terms to return. 0 or an empty
* value means no limit.
* - min_count: The minimum number of results a facet value has to have in
* order to be returned.
* - missing: If TRUE, a facet for all items with no value for this field
* should be returned (if it conforms to limit and min_count).
* - operator: (optional) If the service supports "OR" facets and this key
* contains the string "or", the returned facets should be "OR" facets. If
* the server doesn't support "OR" facets, this key can be ignored.
*
* The basic principle of facets is explained quite well in the
* @link http://en.wikipedia.org/wiki/Faceted_search Wikipedia article on
* "Faceted search" @endlink. Basically, you should return for each field
* filter values which would yield some results when used with the search.
* E.g., if you return for a field $field the term $term with $count results,
* the given $query along with
* $query->condition($field, $term)
* should yield exactly (or about) $count results.
*
* For "OR" facets, all existing filters on the facetted field should be
* ignored for computing the facets.
*
* @param $query
* The SearchApiQueryInterface object to execute.
*
* @return array
* An associative array containing the search results, as required by
* SearchApiQueryInterface::execute().
* In addition, if the "search_api_facets" option is present on the query,
* the results should contain an array of facets in the "search_api_facets"
* key, as specified by the option. The facets array should be keyed by the
* facets' unique identifiers, and contain a numeric array of facet terms,
* sorted descending by result count. A term is represented by an array with
* the following keys:
* - count: Number of results for this term.
* - filter: The filter to apply when selecting this facet term. A filter is
* a string of one of the following forms:
* - "VALUE": Filter by the literal value VALUE (always include the
* quotes, not only for strings).
* - [VALUE1 VALUE2]: Filter for a value between VALUE1 and VALUE2. Use
* parantheses for excluding the border values and square brackets for
* including them. An asterisk (*) can be used as a wildcard. E.g.,
* (* 0) or [* 0) would be a filter for all negative values.
* - !: Filter for items without a value for this field (i.e., the
* "missing" facet).
*
* @throws SearchApiException
* If an error prevented the search from completing.
*/
public function search(SearchApiQueryInterface $query) {
// We assume here that we have an AI search which understands English
// commands.
// First, create the normal search query, without facets.
$search = new SuperCoolAiSearch($query->getIndex());
$search->cmd('create basic search for the following query', $query);
$ret = $search->cmd('return search results in Search API format');
// Then, let's see if we should return any facets.
if ($facets = $query->getOption('search_api_facets')) {
// For the facets, we need all results, not only those in the specified
// range.
$results = $search->cmd('return unlimited search results as a set');
foreach ($facets as $id => $facet) {
$field = $facet['field'];
$limit = empty($facet['limit']) ? 'all' : $facet['limit'];
$min_count = $facet['min_count'];
$missing = $facet['missing'];
$or = isset($facet['operator']) && $facet['operator'] == 'or';
// If this is an "OR" facet, existing filters on the field should be
// ignored for computing the facets.
// You can ignore this if your service class doesn't support the
// "search_api_facets_operator_or" feature.
if ($or) {
// We have to execute another query (in the case of this hypothetical
// search backend, at least) to get the right result set to facet.
$tmp_search = new SuperCoolAiSearch($query->getIndex());
$tmp_search->cmd('create basic search for the following query', $query);
$tmp_search->cmd("remove all conditions for field $field");
$tmp_results = $tmp_search->cmd('return unlimited search results as a set');
}
else {
// Otherwise, we can just use the normal results.
$tmp_results = $results;
}
$filters = array();
if ($search->cmd("$field is a date or numeric field")) {
// For date, integer or float fields, range facets are more useful.
$ranges = $search->cmd("list $limit ranges of field $field in the following set", $tmp_results);
foreach ($ranges as $range) {
if ($range->getCount() >= $min_count) {
// Get the lower and upper bound of the range. * means unlimited.
$lower = $range->getLowerBound();
$lower = ($lower == SuperCoolAiSearch::RANGE_UNLIMITED) ? '*' : $lower;
$upper = $range->getUpperBound();
$upper = ($upper == SuperCoolAiSearch::RANGE_UNLIMITED) ? '*' : $upper;
// Then, see whether the bounds are included in the range. These
// can be specified independently for the lower and upper bound.
// Parentheses are used for exclusive bounds, square brackets are
// used for inclusive bounds.
$lowChar = $range->isLowerBoundInclusive() ? '[' : '(';
$upChar = $range->isUpperBoundInclusive() ? ']' : ')';
// Create the filter, which separates the bounds with a single
// space.
$filter = "$lowChar$lower $upper$upChar";
$filters[$filter] = $range->getCount();
}
}
}
else {
// Otherwise, we use normal single-valued facets.
$terms = $search->cmd("list $limit values of field $field in the following set", $tmp_results);
foreach ($terms as $term) {
if ($term->getCount() >= $min_count) {
// For single-valued terms, we just need to wrap them in quotes.
$filter = '"' . $term->getValue() . '"';
$filters[$filter] = $term->getCount();
}
}
}
// If we should also return a "missing" facet, compute that as the
// number of results without a value for the facet field.
if ($missing) {
$count = $search->cmd("return number of results without field $field in the following set", $tmp_results);
if ($count >= $min_count) {
$filters['!'] = $count;
}
}
// Sort the facets descending by result count.
arsort($filters);
// With the "missing" facet, we might have too many facet terms (unless
// $limit was empty and is therefore now set to "all"). If this is the
// case, remove those with the lowest number of results.
while (is_numeric($limit) && count($filters) > $limit) {
array_pop($filters);
}
// Now add the facet terms to the return value, as specified in the doc
// comment for this method.
foreach ($filters as $filter => $count) {
$ret['search_api_facets'][$id][] = array(
'count' => $count,
'filter' => $filter,
);
}
}
}
// Return the results, which now also includes the facet information.
return $ret;
}
}
<?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) {
// Return terms for this facet.
$this->adapter->addFacet($this->facet, $query);
$settings = $this->getSettings()->settings;
// First check if the facet is enabled for this search.
$default_true = isset($settings['default_true']) ? $settings['default_true'] : TRUE;
$facet_search_ids = isset($settings['facet_search_ids']) ? $settings['facet_search_ids'] : array();
if ($default_true != empty($facet_search_ids[$query->getOption('search id')])) {
// Facet is not enabled for this search ID.
return;
}
// Retrieve the active facet filters.
$active = $this->adapter->getActiveItems($this->facet);
if (empty($active)) {
return;
}
// Create the facet filter, and add a tag to it so that it can be easily
// identified down the line by services when they need to exclude facets.
$operator = $settings['operator'];
if ($operator == FACETAPI_OPERATOR_AND) {
$conjunction = 'AND';
}
elseif ($operator == FACETAPI_OPERATOR_OR) {
$conjunction = 'OR';
// When the operator is OR, remove parent terms from the active ones if
// children are active. If we don't do this, sending a term and its
// parent will produce the same results as just sending the parent.
if (is_callable($this->facet['hierarchy callback']) && !$settings['flatten']) {
// Check the filters in reverse order, to avoid checking parents that
// will afterwards be removed anyways.
$values = array_keys($active);
$parents = call_user_func($this->facet['hierarchy callback'], $values);
foreach (array_reverse($values) as $filter) {
// Skip this filter if it was already removed, or if it is the
// "missing value" filter ("!").
if (!isset($active[$filter]) || !is_numeric($filter)) {
continue;
}
// Go through the entire hierarchy of the value and remove all its
// ancestors.
while (!empty($parents[$filter])) {
$ancestor = array_shift($parents[$filter]);
if (isset($active[$ancestor])) {
unset($active[$ancestor]);
if (!empty($parents[$ancestor])) {
$parents[$filter] = array_merge($parents[$filter], $parents[$ancestor]);
}
}
}
}
}
}
else {
$vars = array(
'%operator' => $operator,
'%facet' => !empty($this->facet['label']) ? $this->facet['label'] : $this->facet['name'],
);
watchdog('search_api_facetapi', 'Unknown facet operator %operator used for facet %facet.', $vars, WATCHDOG_WARNING);
return;
}
$tags = array('facet:' . $this->facet['field']);
$facet_filter = $query->createFilter($conjunction, $tags);
foreach ($active as $filter => $filter_array) {
$field = $this->facet['field'];
$this->addFacetFilter($facet_filter, $field, $filter);
}
// Now add the filter to the query.
$query->filter($facet_filter);
}
/**
* Helper method for setting a facet filter on a query or query filter object.
*/
protected function addFacetFilter($query_filter, $field, $filter) {
// Test if this filter should be negated.
$settings = $this->adapter->getFacet($this->facet)->getSettings();
$exclude = !empty($settings->settings['exclude']);
// Integer (or other non-string) filters might mess up some of the following
// comparison expressions.
$filter = (string) $filter;
if ($filter == '!') {
$query_filter->condition($field, NULL, $exclude ? '<>' : '=');
}
elseif ($filter && $filter[0] == '[' && $filter[strlen($filter) - 1] == ']' && ($pos = strpos($filter, ' TO '))) {
$lower = trim(substr($filter, 1, $pos));
$upper = trim(substr($filter, $pos + 4, -1));
if ($lower == '*' && $upper == '*') {
$query_filter->condition($field, NULL, $exclude ? '=' : '<>');
}
elseif (!$exclude) {
if ($lower != '*') {
// Iff we have a range with two finite boundaries, we set two
// conditions (larger than the lower bound and less than the upper
// bound) and therefore have to make sure that we have an AND
// conjunction for those.
if ($upper != '*' && !($query_filter instanceof SearchApiQueryInterface || $query_filter->getConjunction() === 'AND')) {
$original_query_filter = $query_filter;
$query_filter = new SearchApiQueryFilter('AND');
}
$query_filter->condition($field, $lower, '>=');
}
if ($upper != '*') {
$query_filter->condition($field, $upper, '<=');
}
}
else {
// Same as above, but with inverted logic.
if ($lower != '*') {
if ($upper != '*' && ($query_filter instanceof SearchApiQueryInterface || $query_filter->getConjunction() === 'AND')) {
$original_query_filter = $query_filter;
$query_filter = new SearchApiQueryFilter('OR');
}
$query_filter->condition($field, $lower, '<');
}
if ($upper != '*') {
$query_filter->condition($field, $upper, '>');
}
}
}
else {
$query_filter->condition($field, $filter, $exclude ? '<>' : '=');
}
if (isset($original_query_filter)) {
$original_query_filter->filter($query_filter);
}
}
/**
* Initializes the facet's build array.
*
* @return array
* The initialized render array.
*/
public function build() {
$facet = $this->adapter->getFacet($this->facet);
// The current search per facet is stored in a static variable (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());
$facet_key = $facet['name'] . '@' . $this->adapter->getSearcher();
if (empty($search_ids[$facet_key]) || !search_api_current_search($search_ids[$facet_key])) {
return array();
}
$search_id = $search_ids[$facet_key];
list(, $results) = search_api_current_search($search_id);
$build = array();
// Always include the active facet items.
foreach ($this->adapter->getActiveItems($this->facet) as $filter) {
$build[$filter['value']]['#count'] = 0;
}
// Then, add the facets returned by the server.
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
// representations, convert the format here. (The missing facet can
// stay the same.)
if ($filter[0] == '"') {
$filter = substr($filter, 1, -1);
}
elseif ($filter != '!') {
// This is a range filter.
$filter = substr($filter, 1, -1);
$pos = strpos($filter, ' ');
if ($pos !== FALSE) {
$filter = '[' . substr($filter, 0, $pos) . ' TO ' . substr($filter, $pos + 1) . ']';
}
}
$build[$filter] = array(
'#count' => $value['count'],
);
}
}
return $build;
}
}
<?php
/**
* @file
* Hooks provided by the Search facets module.
*/
/**
* @addtogroup hooks
* @{
*/
/**
* Lets modules alter the search keys that are returned to FacetAPI and used
* in the current search block and breadcrumb trail.
*
* @param string $keys
* The string representing the user's current search query.
* @param SearchApiQuery $query
* The SearchApiQuery object for the current search.
*/
function hook_search_api_facetapi_keys_alter(&$keys, $query) {
if ($keys == '[' . t('all items') . ']') {
// Change $keys to something else, perhaps based on filters in the query
// object.
}
}
/**
* @} End of "addtogroup hooks".
*/
name = Search Facets
description = "Integrate the Search API with the Facet API to provide facetted searches."
dependencies[] = search_api:search_api
dependencies[] = facetapi:facetapi
core = 7.x
package = Search
files[] = plugins/facetapi/adapter.inc