A Belated Post on Squeel Sifters

Comments

A few weeks ago, I added an interesting feature to Squeel, and never really said much about it other than a single tweet and a brief reference in the README. I'm calling the feature "sifters", and I wanted to write a little bit about the purpose they serve.

Scopes Fall Short

We can all generally agree on the principle of DRY, or "Don't Repeat Yourself" when it comes to writing software. In the realm of ActiveRecord, we have a really useful tool in our arsenal to enhance readability and enable easy reuse of query logic. That tool is scope, and it needs no introduction.

In the case of adding conditions to a WHERE clause, scopes work great, so long as you want to add those conditions against the base model being used in your query. We can just write something like:

    class User < ActiveRecord::Base
      scope :name_starts_or_ends_with, 
            lambda {|str| where{(name =~ "#{str}%") | (name =~ "%#{str}")}}
    end

    User.name_starts_or_ends_with('bob')
    # => SELECT "users".* FROM "users"  
         WHERE (("users"."name" LIKE 'bob%' OR "users"."name" LIKE '%bob'))

This works great, for that limited case. But what if we would really like to use that condition against users, but users are part of a join? Scopes fail us, then.

Sure, we could write a scope on, say, an Article class...

class Article < ActiveRecord::Base
  scope :authored_by_users_with_name_starting_or_ending_with,
        lambda {|str| joins(:user).
                      where{user => ((name =~ "#{str}%") | (name =~ "%#{str}"))}
end

...but that's a bad idea for a few reasons, the most problematic being that now we're making assumptions about a User's column names from the article class. There's also the problem of needing a similar method on any other class we might like to filter based on user names.

It would be great if we had a way to:

  1. Write a reusable bundle of conditions against a model's attributes in the code of the model it pertains to
  2. Use these condition bundles even through an association
  3. Abstract away the implementation details of the condition

Sifters Fill the Gap

That's exactly what sifters are designed to solve. A sifter is defined similarly to a scope:

    # Define a sifter via a class macro...
    sifter :name_starts_or_ends_with do |str|
      (name =~ "#{str}%") | (name =~ "%#{str}")
    end
    # ...or a class method...
    def self.name_starts_or_ends_with(str)
      squeel{(name =~ "#{str}%") | (name =~ "%#{str}")}
    end

You can then use the sifter by calling sift within the Squeel DSL block, with the sifter name and its parameters (this example used only one, but you can write sifters that take as many as you like):

    User.where{sift :name_starts_or_ends_with, 'bob'}
    # => SELECT "users".* FROM "users"  
         WHERE (("users"."name" LIKE 'bob%' OR "users"."name" LIKE '%bob'))

    Article.joins(:user).where{user.sift :name_starts_or_ends_with, 'bob'}
    # => SELECT "articles".* FROM "articles" 
         INNER JOIN "users" ON "users"."id" = "articles"."person_id" 
         WHERE (("users"."name" LIKE 'bob%' OR "users"."name" LIKE '%bob'))

That's about it. I hope you find sifters useful!

comments powered by Disqus