Rails Tricks, part 1: Searchlogic

Rails Tricks, part 1: Searchlogic

04 Oct 2009 – Warsaw

This is the first post on a series I plan to write about some clever usage of Rails-related tools (gems, plugins or other) that can really enhance programmer’s and app user’s experience, make code cleaner, and application neater.

Disclaimer In this article’s code examples I use Searchlogic 1.6 branch. I haven’t switched to 2.x with its syntax yet, so the code samples would require some re-writing, albeit the general ideas presented in this article remain valid.

Why Searchlogic is awesome

If you ever had the need to implement some combined searching / filtering / sorting capabilities in your Rails application, chances are you’ve stumbled upon Ben Johnson’s gem searchlogic.

What Searchlogic does great is not only its neat syntax for easy declaration of things that used to be pain in the ass with ActiveRecord.find conditions (think “less than X” and parameter escaping). Searchlogic promotes abstraction of AR.find parameters set as separate entity, a Search Scope — and enables easy conversions to-fro simple Hash. This way a given Scope can be easily saved (serialized), loaded, copied, modified etc.

But before we get into that awesomeness, let’s start by how searchlogic should and should not be used.

Search form: you’re doing it wrong!

Searchlogic’s readme gives an example of simple search/filtering form, submitting parameters that (supposedly) could be fed directly into scope constructor.

This could work and be a proper usage of searchlogic under one condition: it’s in admin panel (trusted user with all priviledges) and admin knows exactly how it works and how should the parameters be fed into it.

But generally it doesn’t work like users of your site would expect. Empty fields aren’t omitted in criteria, they evaluate to “is blank”-kind of conditions (and that’s counter-intuitive)*. If prices are stored as integers instead of decimals (a common design decision, see comments), they’d have to be input that way.
If there are any associations to be joined, there’s some tweaking to do. Plus a clever cracker with a tool as simple as firebug could forge extra fields into this form and get records he shouldn’t be allowed to.

  • —I seems to be working as expected in Searchlogic 2.x branch. Well, yet another reason to switch.

The proper way is to manually build a Scope, based on parameters with proper sanitization and conversion before they get passed to the Scope. I usually abstract Scope building to static method of related model and call it from controller’s private method:

class ItemsController < ApplicationController
  
  def index
    prepare_search
    @items = @scope.all
  end
  
  private
  def prepare_search
    #searchlogic scopes play well with named scopes!
    @scope = Item.active.prepare_search_scope(params)
  end
end

And the Model.prepare_search_scope(params) manually builds Scope by checking on a

class Item < ActiveRecord::Base
  def self.prepare_search_scopes(params = {})
    scope = self.new_search
    
    scope.conditions.title_like = params[:keyword].strip if params[:keyword]
    
    unless(params[:category_id].blank?)
      scope.conditions.category_id = params[:category_id]
    end
    
    if(params[:price_at_least].to_f>0)
      scope.conditions.price_at_least = params[:price_at_least].to_f*100
    end
    
    if(params[:price_at_most].to_f>0)
      scope.conditions.price_at_most = params[:price_at_most].to_f*100 
    end
    
    scope.order_as = "DESC"
    scope.order_by = :created_at
    
    return scope
  end
end

Take a look at this method’s body: it doesn’t depend on class it’s used in. It means that if you have a few models where scopes are built in similar way, it’s a no-brainer to extract this abstraction into module or superclass.

Links for extra parameters

Usually websites don’t expose all their searching/filtering capabilities as form. Usually there are some links, especially for changing category, ordering and other hard-to-input stuff. Creating links to the same page (action, controller, etc.) with the same extra parameters with just one (few) added or changed is extremely simple… just merge these new parameters!

link_to("category 2", params.merge({:category_id => 2}))

It exploits the fact that url_for (used with link_to’s second parameter) builds path from given hash and params hash is a magic one, containing also the controller, action and other extra stuff that’s been already input in the address bar.

Exploiting Scopes for nice browsing info

Many auction sites — like eBay and polish Allegro — provide users searching for some stuff with a nice side menu listing categories relevant to search (i.e. containing items that fulfill the search criteria) and amount of items in each of these categories.

This can be easily achieved using searchlogic by cloning our Scope (preservation of all the manually-built conditions!) and using some SQL-fu (adding grouping and overwriting SELECT part of our query to get just the count).

categories_scope = scope.clone
categories_scope.group = "category_id"
categories_scope.select= "category_id, count(category_id) AS category_count"
categories_scope.order = nil

Of course now we have two Scopes to return, so I personally prefer returning a hash like {:items => scope, :item_categories => categories_scope}.

And yes, of course the above code could use some joins for pulling the relevant categories and thus avoiding N+1 queries problem. Let’s leave that as an exercise for the reader (as for now, this article needs some tweaking anyway).

What more can I do with Scope?

Serialize it (like Hash) and store it in database for later use. For instance, notifying user when there’s some new stuff (just add a created_at condition) fulfilling given criteria defining his interests.

That’s not all, folks

Searchlogic (at least 1.6.x branch I’m using on a daily basis) does also pagination and can play very well with pagination standard in the Rails ecosystem, i.e. mislav’s will_paginate. But mislav-will_paginate tricks are going to be a subject of next post in “Rails Tricks” series.