ActiveRecord Query Building with Multiple and Optional Conditions 8

Posted by unixmonkey on December 29, 2008

For some complex searches, I find myself needing to build a query with some optional conditions that may or may not exist based on search terms.

I could concatenate an SQL string, but when we are dealing with user-supplied input, we need to parameterize the sql query with question marks (?) for binding parameters to to avoid SQL injection attacks.

But I may not know how many parameters I’m actually going to use in a query.

With a form like this:

Show me my friends: 
  living in: [   ]
  whose hobby is: () skating, () fishing, () basketball
  over: [  ] years old

  *(no required fields)

At first stab, you could try something like this:

User.friends.find(:all, :conditions => [
   'town LIKE ? AND hobby = ? AND age >= ?', 
   "%#{params[:town]}%", params[:hobby], params[:age] 
])

This would work, but only if all fields are filled out; otherwise the SQL generated would break looking like this if someone only filled in the name part of the form:

"SELECT * FROM users WHERE town LIKE "%indianapolis%" AND hobby = '' AND age >= ''"

There are some plugins like Ezra’s Ez-Where to handle query building; but how about we try using the built-in symbol key interpolation as described here in ActiveRecord to get the job done.

This lets you use named :symbols in place of question marks for binding.

Find lets you supply your conditions as a 2-element array with a string, and a corresponding hash like so:

User.friends.find(:all, :conditions => [ 
  'town LIKE :town AND hobby = :hobby AND age >= :age', 
  { :town => "%#{params[:town]}%" , :hobby => params[:hobby],  :age = params[:age] }
])

Again, this will work just fine if all fields are filled out; but how do we omit conditions and hash keys?

Our final SQL string can be built by joining conditions with AND (or OR if your app suggests it), and stuffing new key/value pairs into our arguments hash, or with Hash.merge!

First we’ll set up our search method like so:

def results
  conditions  = []
  arguments = {}
 
  unless params[:town].blank?
    conditions << 'town LIKE :town'
    arguments[:town] = "%#{params[:town]}%"
  end
 
  unless params[:hobby].blank?
    conditions << 'hobby = :hobby'
    arguments[:hobby] = params[:hobby]
  end
 
  unless params[:age].blank?
    conditions << 'age >= :age'
    arguments[:age] = params[:age]
  end
 
  all_conditions = conditions.join(' AND ')
 
  @user_friends = User.friends.find(:all, :conditions => [all_conditions, arguments])
end

An alternative is to use Hash.merge! like so:
(this will let you assign more than one key/value pair at a time or to combine hashes)

instead of:

arguments[:town]  = "%#{params[:town]}%"
arguments[:hobby] = params[:hobby]
  arguments.merge!({ 
    :town  => "%#{params[:town]}%",
    :hobby => params[:hobby]
  })

Maybe not as elegant as using a plugin, but certainly clear and flexible.

Trackbacks

Use this link to trackback from your own site.

Comments

Leave a response

  1. Nolan Eakins Mon, 29 Dec 2008 22:25:28 EDT

    After doing stuff like the above more than once a while back, I dug up Condition Builder:
    http://blog.inquirylabs.com/2007/01/04/condition-builder-10-released/
    It keeps you from having to keep track of both the condition string and values.

  2. Elijah Miller Mon, 29 Dec 2008 22:32:52 EDT

    I’ve written more than my share of searches like you describe, but now I tend to use named_scopes for clarity and ease of composing complex conditions.

    Check it: http://gist.github.com/41500

    class Something < ActiveRecord::Base
      # Simpler approach available with this small Rails patch:
      # http://github.com/jqr/rails/commit/d46d7b3cc97d9b4dc4dcd3cc210e645a5497a9b1
      #
      # named_scope :with_town_like, lambda { |term|
      # { :conditions => ['town LIKE ?', term] } unless term.blank?
      # }
      #
      # named_scope :with_hobby, lambda { |term|
      # { :conditions => ['hobby = ?', term] } unless term.blank?
      # }
      #
      # named_scope :age_at_least, lambda { |term|
      # { :conditions => ['age >= ?', term] } unless term.blank?
      # }
     
      named_scope :with_town_like, lambda { |term|
        if !term.blank?
          { :conditions => ['town LIKE ?', term] }
        else
          {}
        end
      }
     
      named_scope :with_hobby, lambda { |term|
        if !term.blank?
          { :conditions => ['hobby = ?', term] }
        else
          {}
        end
      }
     
      named_scope :age_at_least, lambda { |term|
        if !term.blank?
          { :conditions => ['age >= ?', term] }
        else
          {}
        end
      }
     
      def search(options = {})
        town_like(options[:town]).
        with_hobby(options[:hobby]).
        age_at_least(options[:age])
      end
    end
  3. unixmonkey Tue, 30 Dec 2008 15:36:13 EDT

    @jqr, I pulled your gist into the comment.

    If I see code in my comments, I go back after the fact and edit the right tags in.

  4. Daniele Thu, 19 Nov 2009 06:26:35 EDT

    Great article! I’m a newbie, coming from the Java world and I was looking for something similar to Hibernte Criteria with ActiveRecord. I think I found it here. I’m still learning the basics of Ruby and Rails, so I currently prefer your approach than the plugin one. Great snippet.

  5. Mark Hoffman Thu, 31 Dec 2009 17:22:09 EDT

    Another Rails newbie here and I was trying to figure out how to string all my parameters into a query and Google brought me here. I’m sure the other plugins mentioned will work fine, but the approach you gave is clean and does what I need. Thanks for posting it!

  6. brett Fri, 15 Jan 2010 22:34:11 EDT

    Another NOOB here. Took me forever to find this article. I assume this is still up to date given that it’s only a year old. I was starting to get worried that I was missing something really obvious. Thanks so much!

  7. Katherine Lindholm Thu, 22 Sep 2011 07:30:28 EDT

    I’m new on this. I don’t know how to use parameters or html codes. But I will always try to do it, sometimes I can figure it out.

  8. mady Thu, 20 Oct 2011 02:05:51 EDT

    Always up for unix

Comments