http://clearcove.ca/wp-content/themes/press

14 Dec 2008, Posted by Jo Hund in Ruby/Rails, 8 Comments

Recipe: RESTful search for Rails


This recipe shows you how to search, filter, and sort your resource lists in a restful way. We will look at the most simple way to accomplish this and then provide some pointers to further improvements. This recipe works great with will_paginate. It is an end to end solution (model, view, and controller). This recipe requires Rails 2.2.2 or greater if you want to use :joins in named scopes. Otherwise Rails 2.0 is sufficient.

Here is what you will get from this recipe:

List options in the Quentin time tracker

List options in the Quentin time tracker

Overview

Named scopes are an important ingredient to this recipe. I consider them one of the most useful features that have been added to Rails lately. Key is to name them properly and then you can use the scopes’ names all the way from the view, via controller, to the model. Super convenient and clean.

Here is what we do:

Model

  • Define a named scope for every search/filter dimension
  • Define a method called ‘filter’ to retrieve the records from the DB
  • Define a method called ‘list_option_names’ to get a list of possible list options

Controller

  • In the index action, call the Activity.filter method instead of Activity.find
  • Add a private method ‘load_list_options’ to extract the options from the request

View

  • Add a form to your resource list to define the list options
  • Render the resources as list

Example code

As an example application we use a time tracker (See Quentin on github). The resource we want to search on are Activities. Every task has a number of Activities for time worked on this task. And each Activity belongs to a Client and Project.

You can look at the code below or go straight to my Rails recipes on github where I have a sparse Rails app with all the bits and pieces required for RESTful searching in Rails.

Model

First of all let’s define some named scopes that we can use for our searching:

# In app/models/activity.rb

named_scope :search, lambda{ |query|
    q = "%#{query.downcase}%"
    { :conditions => ['lower(activities.description) LIKE ?', q] } }
named_scope :for_project, lambda { |project_ids|
    { :conditions => ['activities.project_id IN (?)', [*project_ids]] } }
# don't call the below 'sort_by'. There seems to be some naming conflict
named_scope :sorted_by, lambda { |sort_option|
    case sort_option.to_s
    when 'most_recent'
      { :o rder => 'activities.created_at DESC' }
    when 'client_project_name_a_to_z'
      { :o rder => 'lower(clients.name) ASC, lower(projects.name) ASC',
        :joins => {:project => :client} }
    when 'duration_shortest_first'
      { :o rder => 'activities.duration ASC' }
    when 'duration_longest_first'
      { :o rder => 'activities.duration DESC' }
    else
      raise(ArgumentError, "Invalid sort option: #{sort_option.inspect}")
    end }

All of my searchable resources have the ’search’ and the ’sorted_by’ named scope. They are used for indexed searching (in this case just using the database LIKE method), and sorting. Starting with Rails 2.2 you can also use :joins in named_scopes to extend your searches across multiple tables.

The :for_project named scope accepts project_ids either as array or as single integer. The [*project_ids] converts either into an array of params for the SQL ‘IN’ clause.

Then we need to define the filter method that composes the query and retrieves the records from the database. This is a class method on Activity:

# In app/models/activity.rb

# applies list options to retrieve matching records from database
def self.filter(list_options)
  raise(ArgumentError, "Expected Hash, got #{list_options.inspect}") \
      unless list_options.is_a?(Hash)
  # compose all filters on AR Collection Proxy
  ar_proxy = Activity
  list_options.each do |key, value|
    next unless self.list_option_names.include?(key) # only consider list options
    next if value.blank? # ignore blank list options
    ar_proxy = ar_proxy.send(key, value) # compose this option
  end
  ar_proxy # return the ActiveRecord proxy object
end

The method above is where the magic happens. The really cool thing about named scopes is that they are composable. This is a really powerful concept where we chain together all the conditions that were defined in the resource filter form. Rails will defer the single DB query with all the conditions until it actually needs the collection. This is what makes this approach so efficient.

The ‘list_option_names’ method returns the names of the list options we use. Again, we use a cool feature of named scopes where we can reflect on the defined named scopes and just remove the ones that are not part of the list options.

# In app/models/activity.rb

# returns array of valid list option names
def self.list_option_names
  self.scopes.map{|s| s.first} - [:named_scope_that_is_not_a_list_option]
end

Controller

The controller has two jobs in this recipe:

  • Get the list options from the submitted resource search form
  • Make the current list options accessible to the resource search form for display

Define the following methods in the activities controller:

We use the index option to render the search results. It behaves like a normal index action if no list_options are given. If they are given, they will be considered when retrieving records from the Database.

# In app/controllers/activities_controller.rb

def index
  @list_options = load_list_options
  @activities = Activity.filter(@list_options).paginate(:page => params[:page])
end

Of course you can add a response block and render the results to HTML, XML, CSV, etc. Notice how I use will_paginate exactly like I would use it with a normal Activity.find.

The ‘load_list_options’ method builds the hash of list options for this request:

# In app/controllers/activities_controller.rb

private

def load_list_options
  # define default list options here. They will be used if none are given
  options = {:sorted_by => 'most_recent'}
  # find relevant query parameters and override list options
  Activity.list_option_names.each do |name|
    options[name] = params[name] unless params[name].blank?
  end
  options
end

We use the list of list_option_names to extract the relevant params and store them in an instance variable so that they are available to the view.

View

Finally we get to see the results of all this work:

# In app/views/activities/index.html.erb

<h1>Activities</h1>
<!-- <pre><%#= @list_options.to_yaml %></pre> -->

<div id="list_options">
  <% form_tag(activities_path, :method => :get) do %>
    <dl>
      <dt>Search</dt>
      <dd><%= text_field_tag('search', @list_options[:search]) %></dd>
      <dt>Project</dt>
      <dd>
        <%= select_tag('for_project', options_for_select(
          Project.all_with_client.sorted_by('client_and_name_a_to_z').map{|p|
              [p.client_and_name, p.id.to_s]}.unshift(['- Any -', nil]),
          @list_options[:for_project])) %>
      </dd>
      <dt>Sorted by</dt>
      <dd>
        <%= select_tag('sorted_by',options_for_select(
          [
            ['most recent', 'most_recent'],
            ['client, project name (a-z)', 'client_project_name_a_to_z'],
            ['duration (longest first)', 'duration_longest_first'],
            ['duration (shortest first)', 'duration_shortest_first']
          ],
          @list_options[:sorted_by])) %>
      </dd>
      <dt></dt>
      <dd>
        <%= submit_tag "Update list" %> or
        <%= link_to('Reset', activities_path) %>
      </dd>
    </dl>
  <% end %>
</div>

<div class="pagination_group">
  <%= will_paginate(@activities) %>
  <%= page_entries_info(@activities) %>
</div>

<div>
  <table>
    <tr>
      <th>Client | Project</th>
      <th>Description</th>
      <th>Duration</th>
      <th>Date</th>
    </tr>
    <% @activities.each do |activity| %>
      <%= render :partial => activity %>
    <% end %>
  </table>
</div>

A few comments on the view:

  • Near the top is some commented out code that is helpful for debugging list options.
  • The form submits to the standard route for the activities index action via the GET method. This has the advantage that you can bookmark a particular search and link to it directly with all the search params in the URL.
  • The search inputs are standard form fields that get their default values from the @list_options hash. So you always see what the settings for the current search are.
  • For the ‘Project’ select input, I add the ‘Any’ option with a nil value. The ‘Any’ option will be ignored when composing the AR conditions because the value is nil. This is exactly what we want: If we don’t want a particular client, then the named scope ‘with_client’ should not be used.
  • Also on the client select, note that I typecast the id to string. This is important to show the correct selected option, because all @list_options params are in string format from the request params.
  • Finally the ‘Reset’ link just points to the index action with no params given. In this case the default list options will be used.
  • I added basic code for pagination. See will_paginate documentation for more info.
  • Finally the list of resources is rendered using a partial.

Additional features

  • Sometimes it makes sense to persist the list options in the session so the list remembers my settings when I come back to it during the same session. The place to do this is in the controller’s ‘load_list_options’ method.
  • You could Ajaxify this quite easily; I decided not to show it to make the essential stuff more visible.
  • Add preset links for common filter settings:
    link_to('Longest phone calls', activities_path(:search => 'call', :sorted_by => 'duration_longest_first'))
  • Namespace the list option params. Form input fields would be ‘list_options[sorted_by]‘ instead of ’sorted_by’. This might be necessary if you have a more complex app where there might be parameter name conflicts.
  • Make @list_options an AR object so you can use the ‘form_for(…) do |f|’ form, validations, and datetime type casts in your filter forms. You can also save searches for later use.
  • Use a form builder to build the filter control inputs. This way you could minimize the filter form portion of your view code (example: f.sort_by_select).
  • I haven’t tried it, but you might be able to use subqueries in your named_scopes as well. See this article for more info.

Other resources

  • Railscasts episode 37 (full stack solution, doesn’t use named scopes)
  • Ben Curtis (full stack solution, doesn’t use named scopes)
  • Courtenay (uses named_scopes, puts stuff in the controller that doesn’t belong there, thanks for the tip on the :joins bug)
  • TechnoWeenie (not a full stack solution. Provides convenience named_scopes for AR models)

8 Comments

January 17, 2009 3:00 pm

Shane

Thanks for the write up! Just implemented it on my order system and it works great!

January 18, 2009 4:18 pm

Laszlo

Thank you for the elegant solution. Exactly what I needed and and it worked perfectly.

March 19, 2009 6:17 am

Alexey

Thank you for nice tutorial. Works like a charm!

March 20, 2009 8:03 am

Steve

Great article.

I am a little confused about what this is doing:


def self.list_option_names
self.scopes.map{|s| s.first} - [:named_scope_that_is_not_a_list_option] end

specifically the

- [:named_scope_that_is_not_a_list_option]

How do scopes become “list options”? When I run this I get the same result with or without that line.

March 20 2009 08:16 am

Jo Hund

@Steve: the list_option_names class method returns the names of all the named scopes you want to expose as list_options. You might have a named_scope that you use only internally. So in this case you would replace :named_scope_that_is_not_a_list_option with the name of the named_scope you don't want to expose as a list option.

A scope becomes a list option when its name is returned from the list_option_names method. The method grabs the names of all defined scopes (self.scopes.map{|s| s.first} and removes the names of those you don't want as list_options (- [ the list of scopes to be removed goes here ] ).

In my recipe, the :named_scope_that_is_not_a_list_option is a bogus name, representing whatever you might want to put in here.

April 1, 2009 4:38 pm

Arnþór Snær

Do I understand correctly that with this recipe, I can not have the search parameters in the URL without breaking my “clean” URL’s?

That is, if my code now generates these kinds of paths: controller/action/happy/2007

by going restful and using your recipe i will have to have this implementation: controller/action?term=happy&year=2007

Right?

April 15 2009 02:01 am

Jo Hund

Yes, you append any list option as query string params to the URL, as you wrote in your comment.

November 19, 2009 7:43 am

Heiko

Thanks for your recipe! Exactly what I was looking for.

I have a very similar project running and I need to filter my projects for more than just one project.

Could anyone please post a hint how to get this working for filtering multiple Clients/projects (according to the example screenshot above). I tried to figure out on my own but I’m stuck.

Thanks in advance!

Posting your comment...

Leave A Comment


Subscribe to this comment via Email