Simple Model Search with Rails

Comments

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!

comments powered by Disqus