Simple Model Search with Rails
CommentsOne of the first things I think that many new Rails developers set out to do is to find a way to handle basic search forms in a reusable way.
Inspired by Jamis Buck’s refactoring of a Report model and controller at The Rails Way, this is one I recently worked out for the first Rails application I’ve gotten to develop for my employer, a database that tracks AUP violations and other offenses by our customers.
I wanted to be able to wrap my search form in a form_for against a Search model like I would for displaying any typical ActiveRecord model.
app/views/offenses/index.html.erb:
<% form_for :search, @search,
:html => { :method => :get } do |f| %>
<%= f.label :offense_type_id, "Type" %>
<%= f.collection_select :offense_type_id, @offensetypes,
:id_before_type_cast, :name,
{ :include_blank => true } %>
<br />
<%= f.label :offense_level_id, "Level" %>
<%= f.collection_select :offense_level_id, @offenselevels,
:id_before_type_cast, :name,
{ :include_blank => true } %>
<br />
<%= f.label :investigation_number, "Investigation #" %>
<%= f.text_field :investigation_number %>
<br />
<%= f.label :created_from, "Created after" %>
<%= f.date_select :created_from, :order => [:month, :day, :year],
:include_blank => true %>
(more...)
<% end %>
Note the :get method being used – this is both to convince the index action that I’m not intending to create a new record, and to make sure that we have a nice (albeit long) copy/pastable URL to send someone with a search we’ve done.
Now for the controller code.
app/controllers/offenses_controller.rb:
def index
munge_params
@offenses = []
@search = Search.new(Offense,params[:search])
if @search.conditions
@offenses = Offense.search(@search, :page => params[:page])
end
respond_to do |format|
format.html # index.html.erb
format.xml { render :xml => @offenses }
end
end
We’re going to pass a named page parameter because we’re going to call Offense.paginate instead of Offense.find to use the excellent will_paginate plugin.
Now to the simple Offense.search method.
app/models/offense.rb:
# The "s" parameter is an instance of Search, instantiated from form input.
def self.search(s, args = {})
Offense.paginate(:all, :conditions => s.conditions, :page => args[:page],
:per_page => 100, :order => 'offenses.created_at',
:include => [:offense_level, :offense_type,
:account_type, :account_status, :site])
end
And finally, we’ve managed to push almost all the hard work of creating those conditions off to the Search model. Here we go!
app/models/search.rb
class Search
attr_reader :options
def initialize(model, options)
@model = model
@options = options || {}
end
def created_from
date_from_options(:created_from)
end
def created_to
date_from_options(:created_to)
end
def updated_from
date_from_options(:updated_from)
end
def updated_to
date_from_options(:updated_to)
end
def modem_mac
options[:modem_mac].to_s.gsub(/[^0-9a-f]/i, '').upcase
end
# method_missing will autogenerate an accessor for any attribute other
# than the methods already written. I love this magic. :)
def method_missing(method_id, *arguments)
if @model.column_names.include?(method_id.to_s)
options[method_id].to_s
else
raise NoMethodError, "undefined method #{method_id}"
end
end
def conditions
conditions = []
parameters = []
return nil if options.empty?
if created_from
conditions << "#{@model.table_name}.created_at >= ?"
parameters << created_from.to_time
end
if created_to
conditions << "#{@model.table_name}.created_at <= ?"
parameters << created_to.to_time.end_of_day
end
if updated_from
conditions << "#{@model.table_name}.updated_at >= ?"
parameters << updated_from.to_time
end
if updated_to
conditions << "#{@model.table_name}.updated_at <= ?"
parameters << updated_to.to_time.end_of_day
end
# note that we're using self.send to make sure we use the getter methods
# so that stuff like modem_mac gets its proper formatting in parameters
options.each_key do |k|
next unless @model.column_names.include?(k.to_s)
v = self.send(k) unless k == :conditions # No infinite recursion for you.
next if v.blank?
if k =~ /_id$/
conditions << "#{@model.table_name}.#{k} = ?"
parameters << v.to_i
else
conditions << "#{@model.table_name}.#{k} LIKE ?"
parameters << "%#{v}%"
end
end
unless conditions.empty?
[conditions.join(" AND "), *parameters]
else
nil
end
end
private
# Just like the one in the Report model, but just for dates instead of times.
# Using a Proc to generate input parameter names like those for date_select.
def date_from_options(which)
part = Proc.new { |n| options["#{which}(#{n}i)"] }
y,m,d = part[1], part[2], part[3]
y = Date.today.year if y.blank?
Date.new(y.to_i, m.to_i, d.to_i)
rescue ArgumentError => e
return nil
end
end
A few things we’re doing here of note. We’re using the date_from_options method that is a slightly modified version of Jamis’s previously-mentioned example to emulate the way that date_select expects to see date values returned from ActiveRecord objects. We’re making year an optional field, defaulting it to the current year if not supplied.
We also happen to be storing the MAC addresses of cable modems, but we store them without formatting, so we’re making sure that our modem_mac attribute behaves the same way. You could probably imagine how this could be extended to any special-case attributes you commonly store in your models.
With the special-case attributes of dates and MAC addresses out of the way, we’re tasked with creating a reader for every single remaining attribute of the Offense model. Oh, wait, no we aren’t – method_missing to the rescue! To avoid readers for attributes that don’t exist, we’ll just do a quick check that the requested attribute is a valid attribute of the model we’ve been instantiated to search.
The real magic of returning a valid value for ActiveRecord’s find method is saved for the conditions method. First we handle date range searching, then we just call the attribute readers for all other parameters that were supplied. If the param ends in _id we assume it to be an integer, like the ones from the lookup tables we use for our collection_selects, and do an equality test. Otherwise, we add a LIKE condition, and wildcard both ends of it.
It’s important to include the model’s table name in the generated SQL, or we won’t be compatible with eager loading queries like the one generated in our Offense.search method.
One last gotcha. We’re checking to see if the param matches a column in the corresponding table, but what if some (not so) clever developer wraps this thing around a model with an attribute called conditions? This would be bad, so we check to make sure that’s not the case before landing ourselves in infinite recursion.
Once we’ve assembled our SQL and substitution arrays, we convert them to a format that works for ActiveRecord#find and there you go!
Here’s the finished Search model: Simple Model Search
I hope this has been helpful in some way, as it’s my first Rails-related public post. I’m sure there are improvements that could be made to this model, but it’s working really well in this application so far!
comments powered by Disqus