The Ruby on Rails and ClojureScript experts

Dec 14, 2008

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'
      { :order => 'activities.created_at DESC' }
    when 'client_project_name_a_to_z'
      { :order => 'lower(clients.name) ASC, lower(projects.name) ASC',
        :joins => {:project => :client} }
    when 'duration_shortest_first'
      { :order => 'activities.duration ASC' }
    when 'duration_longest_first'
      { :order => '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: <pre class="brush: xml; title: ; notranslate" title="">link_to(‘Longest phone calls’, activities_path(:search => ‘call’, :sorted_by => ‘duration_longest_first’))</pre>

  • 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)