Ernie Miller

No, I don't work in NYC, DC, or the valley, and I'm cool with that.

RSS Feed

Simple Model Search with Rails

Posted by Ernie on February 7, 2008 at 12:52 pm

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

Filed under Uncategorized
Tagged as ,
You can leave a comment, or trackback from your own site.

About

I'm Ernie Miller. But then, you probably knew that by looking at the page title, or the URL. I'm a Ruby programmer in Louisville, Kentucky. This blog used to be called "metautonomo.us", which I thought was kind of clever, but nobody, including me, could type it. Lesson learned.