How to share code among ActiveRecord models

My last project was migrating a Rails 2.3 app to Rails 3.2. At some point I was updating tens of definitions of named scopes, a pretty easy and tedious task.

When I see many similar code snippets, generalizations popup in my mind. That never fails, and this time I found many named scopes that could be parametrized in some way or another. So I did it and started replacing those occurrences with calls to my generic scopes.

Later on, when it came time to test whether my changes worked or not… (f*ck) I found out there was a pretty good reason why a bunch of them were the way they were, i.e. not parametrized. Aargh

The first generic scope: or_where

To explain the issue I need to take a little detour. One scope I could not live without anymore was the where with an OR, i.e. the where that makes the OR of its arguments. It’s very strange that Rails implements only the where with an AND. I mean, anyone knows that AND and OR go hand in hand…

module GenericScopes

  module ClassMethods

    def merge_conditions_with_or(*conditions)
      segments = []
      conditions.each do |condition|
        unless condition.blank?
          sql = sanitize_sql(condition)
          segments << sql unless sql.blank?
        end
      end
      "(#{segments.join(') or (')})" unless segments.empty?
    end

  end



  def self.included(model)

    model.extend ClassMethods

    model.class_eval do

      model.named_scope :or_where, lambda { |*conditions|
        split_conditions = []
        conditions.each { |condition|
          if condition.is_a?(Hash)
            condition.each { |key, value|
              split_conditions << {key => value}
            }
          else
            split_conditions << condition
          end
        }
        sql = merge_conditions_with_or(*split_conditions)
        where(sql)
      }

    end

  end

end

About the GenericScopes module, the interesting thing to note is that it will work without modifications with the final solution I’m going to describe here. In fact, its code could be buggy (I didn’t even try to make it work as nicely as the default where) but it works pretty fine for my own needs. (See an example at the end of this post.)

The problem with the default where is that the AND of the arguments is hardcoded into the merge_conditions method. Instead of changing anything into the ActiveRecord code, I opted for copy-and-pasting that method and replacing the AND with an OR. I use lower case SQL so that when I inspect queries I can easily understand if a piece of query was generated by me or by Rails.

The issue I’m trying to explain pops up with the call to sanitize_sql from merge_conditions_with_or. If the method was public it could work directly like this

User.sanitize_sql(:full_name => 'John Doe')
  # => ["`users`.`full_name` = 'John Doe'"]

but, if an exception wasn’t raised, it would be like this

User.or_where(:full_name => 'John Doe') 
  # internally, the sanitize_sql would return 
  # => ["``.`full_name` = 'John Doe'"]

In fact, when the call is applied to User, and when it takes place inside of merge_conditions_with_or (even if called from or_where which is in turn applied to User), the values of self are completely different. While in the former case it’s User (concrete), in the latter self is ActiveRecord::Base (abstract).

Problem

The problem was the way generic scopes were made available to models.

ActiveRecord::Base.send(:include, GenericScopes)

The inclusion of GenericScopes into ActiveRecord::Base was the cause of all troubles with sanitize_sql. It had been working fine for years in production, but for my new scopes I needed to move the inclusion of the module from the abstract ActiveRecord::Base to each and every concrete model, like this:

class User
  include GenericScopes
  #...
end

class Post
  include GenericScopes
  #...
end

#...

Solution

That is quite boring and you must remember to include GenericScopes in all models. Luckily Ruby provides the inherited method. The following code is then a pretty good way to share code among ActiveRecord models. The nice things are that (1) it follows the DRY principle, (2) it is completely automatic, (3) shared code is properly bound to the inheriting class.

module ActiveRecord
  class Base
    class << self
      def inherited_with_generic_scopes(model)
        inherited_without_generic_scopes(model)
        model.send(:include, GenericScopes)
      end
      alias_method_chain :inherited, :generic_scopes
    end
  end
end

being or being_not, that is the question

It’s very common to have many boolean columns in the same table, so it’s useful to have a generic scope for them. Using where and or_where, these are all the combinations:

model.named_scope :being,        lambda { |*columns|    where(columns.map{|x| {:"#{x}" => true}}.reduce(&:merge)) }
model.named_scope :or_being,     lambda { |*columns| or_where(columns.map{|x| {:"#{x}" => true}}.reduce(&:merge)) }
model.named_scope :being_not,    lambda { |*columns|    where(columns.map{|x| {:"#{x}" => false}}.reduce(&:merge)) }
model.named_scope :or_being_not, lambda { |*columns| or_where(columns.map{|x| {:"#{x}" => false}}.reduce(&:merge)) }

And here is an example about how to use them:

Song.or_being('reggae', 'ska', 'ska_punk')

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.