Facet queries? Making custom Solr facets for fun and profit.

  • 7 minute read

It sounded like a really simple request: "Is it easy to add a search filter for 'My posts'?". In other words, add a search result facet for posts by the current (logged in) user through the Apache Solr Search Integration module APIs?

But then the wheels start turning - we want not just one blind link, but a real facet link that tells us how many results we'll get. Also, if we are filtering by 'My posts' then we probably have an equal use case for the opposite filter 'Posts not by me'. So we really need a facet block with two links and facets counts.

Thanks to the fabulous feature set of Apache Solr 1.4, figuring out how to code up a basic implementation of this turned out to require just a few minutes on the Solr documentation wiki exploring facet options to settle on facet queries as the right solution for this problem. Facet queries let you define new facets based on arbitrary query syntax. This example is a pretty simple one, but I hope you can expand it to any sort of custom set of filters or facets that your Drupal + Apache Solr integration requires. Here's what the result looks like:

You can add something like this code to a site-custom module (MYMODULE). Much of it is a quick copy/modify from existing Apache Solr Search Integration module code:

<?php
/**
* Implementation of hook_block().
*
* Creates site-specific blocks.
*/
function MYMODULE_block($op = 'list', $delta = 0) {
  if ($op == 'list') {
    $blocks['apachesolr_userfilter']['info'] = t('MYMODULE: Filter by current user');
    $blocks['apachesolr_userfilter']['status'] = 1;
    $blocks['apachesolr_userfilter']['region'] = 'right';
    return $blocks;
  }
  elseif ($op == 'view') {
    $block = array();
    switch($delta) {
      case 'apachesolr_userfilter':
        if (apachesolr_has_searched()) {
          // Get the query and response. Without these no blocks make sense.
          $response = apachesolr_static_response_cache();
          if (empty($response)) {
            break;
          }
          $block = MYMODULE_userfilter_facet_block($response, apachesolr_current_query());      
        }
        break;
    }
    return $block;
  }
}


/**
* Implements hook_apachesolr_prepare_query().
*/
function MYMODULE_apachesolr_prepare_query(&$query, &$params, $caller) {
  global $user;
  // The current user filter is only relevant for authenticated users.
  if ($user->uid > 0 && $caller == 'apachesolr_search') {
    // Add a facet for only the current user's posts.
    $params['facet.query'][] = "uid:{$user->uid}";
    // Invert the query to get the other facet.
    $params['facet.query'][] = "-uid:{$user->uid}";
  }
}


/**
* Helper function for displaying a facet block.
*/
function MYMODULE_userfilter_facet_block($response, $query) {
  global $user;
  if (!empty($response->facet_counts->facet_queries) && ($user->uid > 0)) {
    $block['subject'] = t('Filter by author');
    // Define the parameters for each of the custom facets.
    $filter["uid:{$user->uid}"] = array('#exclude' => FALSE, '#name' => 'uid', '#value' => $user->uid);
    $filter["uid:{$user->uid}"]['text'] = t('Only my posts');
    $filter["-uid:{$user->uid}"] = array('#exclude' => TRUE, '#name' => 'uid', '#value' => $user->uid);
    $filter["-uid:{$user->uid}"]['text'] = t("Only others' posts");
    $contains_active = FALSE;
    $items = array();
    foreach ($response->facet_counts->facet_queries as $facet => $count) {
      if ($count == 0) {
        // Not useful to show filters that give zero results.
        continue;
      }
      $options = array();
      // We are only interested in the filters we defined above.
      if (isset($filter[$facet]['text'])) {
        $facet_text = $filter[$facet]['text'];
      }
      else {
        // Someone else added a facet.query?
        continue;
      }
      $active = FALSE;
      // The facet is active if a matching filter was found in the URL.
      foreach($query->get_filters('uid') as $values) {
        if ($values['#name'] == $filter[$facet]['#name'] && $values['#value'] == $filter[$facet]['#value'] && $values['#exclude'] == $filter[$facet]['#exclude']) {
          $active = TRUE;
        }
      }
      // Clone the query since all objects are passed by reference.
      $new_query = clone $query;
      if ($active) {
        // We already applied the filter, so give the user the option to
        // remove it with an unclick link.
        $contains_active = TRUE;
        $new_query->remove_filter('uid', $filter[$facet]['#value'], $filter[$facet]['#exclude']);
        $options['query'] = $new_query->get_url_queryvalues();
        $link = theme('apachesolr_unclick_link', $facet_text, $new_query->get_path(), $options);
      }
      else {
        // Add a facet link that will apply the filter.
        $new_query->add_filter('uid', $filter[$facet]['#value'], $filter[$facet]['#exclude']);
        $options['query'] = $new_query->get_url_queryvalues();
        $link = theme('apachesolr_facet_link', $facet_text, $new_query->get_path(), $options, $count, FALSE, $response->response->numFound);
      }
      if ($count || $active) {
        $items[] = $link;
      }
    }
    // Unless a facet is active only display 2 or more.
    if ($items && ($response->response->numFound > 1 || $contains_active)) {
      return array('subject' => t('Filter by author'), 'content' => theme('item_list', $items));
    }
  }
  return NULL;
}
?>

This bit of code generally assumes that we are only requesting one set of facet queries from Solr. However, since we check if each query is set in our list of known ones, it should be possible to replicate this code multiple times with different facet queries.

To get a really useful set of links in the current search block, we need to replace the default implementation of theme_apachesolr_breadcrumb_uid() so that it handles the case of a user being excluded. Put something like the following in the template.php of your theme (MYTHEME):

<?php
/**
* Return the username from $uid
*/
function MYTHEME_apachesolr_breadcrumb_uid($uid, $exclude = FALSE) {
  $prefix = $exclude ? 'not by ' : '';
  if ($uid == 0) {
    return $prefix . variable_get('anonymous', t('Anonymous'));
  }
  else {
    return $prefix . db_result(db_query("SELECT name FROM {users} WHERE uid = %d", $uid));
  }
}
?>

The implementation here assumes you are not using the 'Filter by author' block provided by the Apache Solr Search Integration module. They will probably work ok together, but there may be some weird interactions.

As sometimes happens, this exercise also revealed some limitations of the current Apache Solr Search Integration module code, and I opened up a couple issues for us to address them and make this example even simpler. Join the fun at http://drupal.org/node/897654 and http://drupal.org/node/897666