Igor's Blog

"... no matter what they tell you, it's always a people problem!"

Thursday, February 22, 2007

Filtering has_many Associations in Rails


... intro


A few months ago, I read Jamis Buck's blog entry Extending ActiveRecord associations. It's about filtering and memorizing the results for has_many association based on some conditions. I found this to be useful and I bookmark it. Sure enough, it happens that I just needed similar functionality. However, it didn’t work exactly as I thought. I decided to post it here, so if I am doing something wrong, hopefully, somebody can correct me or in case somebody else has similar issues.



So, this is an exact copy form Jamis’s blog entry of :



class Project < ActiveRecord::Base
has_many :tasks, :dependent => :delete_all do
memoized_finder :active, "status = 'active'"
memoized_finder :inactive, "status = 'inactive'"
end
end


class Module
def memoized_finder(name, conditions=nil)
class_eval <<-STR
def #{name}(reload=false)
@#{name} = nil if reload
@#{name} ||= find(:all, :conditions => #{conditions.inspect})
end
STR
end
end

... evaluation


I created a file “/lib/ memoized_finder.rb” and included it in “/config/environment.rb”:



# Bootstrap the Rails environment, frameworks, and default configuration
require File.join(File.dirname(__FILE__), 'boot')
require File.join(File.dirname(__FILE__), '/../lib/memorize_finder')

Rails::Initializer.run do |config|
... ...

as it was mentioned in the comments for this post.


Then, I specified the expected behavior using rspec:



context "Project has many active or inactive tasks" do
fixtures :projects, :tasks

setup do
@project = projects :project_with_task
end

specify "should filter out active tasks" do
active_tasks = @project.active_tasks

active_tasks.size.should_be_eql 2
active_tasks.each {|t| t.status.should_be_eql 'active'}
end
end


When I ran the specification for the first time, I had:
“undefined method active_tasks for #


... investigation


I didn’t expect that. I mean many people commented on this blog entry but nobody mentioned something about that. Well, I didn’t know what I am doing wrong (I still don’t know what I am doing wrong) but I did a quick search for more on ‘class_eval’ and found this blog entry: http://neeraj.name/2007/01/31/ruby-class_eval-in-detail/



This example from that blog led me think that I need the class object, since the class_eval method starts with class object. This is excerpt form that blog:



def add_method(obj)
# Get the class of the object.
obj.class.class_eval do
# Add a new method to the class.
def to_string
to_s
end
end
end

... action

So, I introduce clazz parameter to the module:



class Module
def memoized_finder(clazz, name, conditions=nil)
clazz.class_eval <<-STR
def #{name}(reload=false)
@#{name} = nil if reload
@#{name} ||= find(:all, :conditions => #{conditions.inspect})
end
STR
end
end


I had a failure again, but this time I had the method active_tasks defined. The problem was that it didn’t recognize find() method. This make me believe, that this module should be probably included in the Project class somehow, but I am not currently aware of how.


... revelation


Anyway, the problem is not only that it can’t recognize find method, but that the find method should be executed on the Task class, which is from the other side of the has_many association. So, I passed this class as a parameter as well:



class Module
def memoized_finder(clazz, name, conditions=nil, condition_class=nil)
condition_class ||= clazz
clazz.class_eval <<-STR
def #{name}(reload=false)
@#{name} = nil if reload
@#{name} ||= #{condition_class}.find(:all, :conditions => #{conditions.inspect})
end
STR
end
end


Well, this time the specification executed property but didn’t meet the expectation, since the number of active task for this object were more than the expected ones. This was due to the fact that the method that was specified would’ve retrieved all of the active tasks for all projects and not only for the current one. Specifying the expected behavior first was beneficial again! Without this specification, I could’ve thought that I am done and I would’ve moved forwards.


... modification


So, I have to send the id of the Project as part of the sql condition. Somebody had the same question from the blog comments - how to send the id of the Project object, since if you specify id in the association block code, this would not be the id of the Project object. The suggestion is to use escape character. The code should look like this:



class Project < ActiveRecord::Base
has_many :tasks do
memoized_finder Project, :active, "project_id = \#{id} AND status = 'active'", Task
memoized_finder Project, :inactive, " project_id = \#{id} AND status = 'inactive'", Task
end
end


Well, this didn’t work since the expression \#{id} was evaluated in the condition to #{id}. This is because we have: #{conditions.inspect} in the eval_class method evaluation. I am not sure why we need the inspect method, but once I removed it, it start working.


... actually


I just want to mention here that I don’t use strings for defining the status of the tasks (or whatever my actual class is). Instead, I use enumeration through the “has_enumerated” rails plugging. I prefer to work with reference tables for things like those and avoid duplication in the code or in the database. So, I have additional class:



class TaskStatus < ActiveRecord::Base
acts_as_enumerated
end

... finally


This changes the code in the Project class to:



class Project < ActiveRecord::Base
has_many :tasks do
TaskStatus.find(:all).each do |status|
memoized_finder Project, status.name.downcase.pluralize , "project_id = \#{id} AND task_status_id = #{status.id}", Task
end
end
end

... epilogue


I am not sure this is the best solution but it is working for me and somebody else could find it useful. Otherwise, you can comment on how I can improve it. Thanks!

|| Igor, Thursday, February 22, 2007 || link || (3) comments | links to this post