Search as a First-Class Object

Ryan Stenberg, Former Developer

Article Category: #Code

Posted on

One of Viget's recent internal projects, SocialPiq, had some pretty heavy requirements surrounding user-driven search. The main feature of the site was to allow users to search by a number of various criteria, many of which were backed by ActiveRecord models.

Fortunately, one of Rails' strengths is its ability to associate objects and allow easy inspection and traversal of relationships. We could make a form from scratch using a combination of #text_field, #select, and #collection_select; however, we'd have to tell our controller how to interpret the search parameters and how to match and fetch results. Why not have Rails and its built-in constructs do most of that work for us?

First-Class Search Object

Instead of having to fill in all the logic ourselves, we can create an ActiveRecord model to represent a single search. We'll call this model Search. With this approach, each search is an instance of our Search model that can be passed around, respond to method calls, and be persisted in our database. We can create associations to any of the other models that we want to be included as search critera.

For example, in Socialpiq, users needed to be able to select a Capability as well as any number of SocialSites via SiteIntegrations. Capability, SocialSite, and SiteIntegration are models, so we can set up associations for each of them. In addition, lets say we're trying to match against a Tool and we want a results method that gives us all the tools for a given search. Here's what our model might look like:

class Search < ActiveRecord::Base
  belongs_to :capability
  has_many :site_integrations
  has_many :social_sites, through: :site_integrations

  def results
    @results ||= begin
      tools = Tool.joins(:site_integrations)
      matched_tools = scope.empty? ? tools : tools.where(scope)
      matched_tools.distinct
    end
  end

  private

  def scope
    {
      capability_id: capability_id,
      site_integrations: site_ids_scope
    }.delete_if { |key, value| value.nil? }
  end
 
  def site_ids_scope
    ids = social_sites.pluck(:id)
    { social_site_id: ids } if ids.any?
  end
end

Breaking Down the Model

There are two main things we're doing in our model.

  1. Defining our associations
  2. Defining a results method along with a few private helper methods to aid in finding our search results.

The purpose for our model is to look at a given Search and compare its associated records against the associated records for each Tool. For example, if a Search has the same Capability as a Tool, we want to include that Tool in our results set.

To do this, we can utilize Rails' querying methods to find matching Tools. Our scope method returns a hash based on the ids of our search's associated records, which we can simply feed into the where query method (like Tool.where(scope)). In our case, we want to show all records when a user doesn't select a value for given search criteria. To handle that, when a Search doesn't have any associated records, its scope method returns an empty hash, which we'll check for and then return all the tools instead of calling where with an empty scope.

The Search Form

With our Search model and using the SimpleForm gem, we get a beautifully simple form:

<%= simple_form_for @search do |f| %>
  <%= f.association :capability, include_blank: 'Any' %>
  <%= f.association :social_sites, include_blank: 'Any' %>
  <%= f.button :submit, 'Search' %>
<% end %>

Super clean! What happens in our controller once we get the parameters from the search form submission though?

The Search Controller

Again, when we're following Rails conventions, everything seems to drop in really well:

class SearchesController < ApplicationController
  def new
    @search = Search.new
  end
 
  def create
    @search = Search.create(search_params)
    redirect_to search_path(search), notice: "#{search.results.size} results found."
  end
 
  def show
  end
 
  private
  
  def search_params
    params.require(:search).permit(:capability_id, social_site_ids: [])
  end

  def search
    @search ||= Search.find(params[:id])
  end 
  helper_method :search
end

Once users submit our search form, they'll be taken to the show page for a search, where we can simply call search.results to get a list of matching tools. Since we're persisting searches, we could easily add edit and update actions to our controller, allowing users to fine-tune their searches without having to start from scratch.

A Note on ActiveRecord vs. ActiveModel Searches

You may choose to persist your searches, creating a full-fledged Rails model inheriting from ActiveRecord::Base, as I've illustrated in our example. However, if searches don't need to be persisted, check out ActiveModel which lets you include other ActiveModel modules like validations and callbacks.

Recap

By making Search a first-class object in our application, we're able to create a well-defined model (literally) of our search and its criteria, simplify the form, work with Rails conventions in our controller, and get persisted searches practically free. Next time you're in a situation where you need to construct custom searches across your models, consider making Search a first-class object for great justice!

Related Articles