Search as a First-Class Object
Ryan Stenberg, Former Developer
Article Category:
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.
- Defining our associations
- Defining a
resultsmethod 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!