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