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
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!