Graveyard

Tuesday, March 25, 2008

RoR: Caching Dynamic Association Conditions

The problem which is verbosely described in a previous post on dynamic associations still does not have a clean solution - I've been researching possible workarounds, and there is no definite answer. One feasible workaround is specifying :conditions => 'send(:method)' in single quotes, this way Rails will only eval the conditions when forming the SQL string. This, and what was suggested in my previous post, both would work... Unless you would want to reuse that association with different (or no) conditions later on.

And here comes the trouble - Rails preloads all model classes on startup, and any changes that your controller actions do to the model classes stay cached for all further requests. So on production system, modifying an association in run-time would affect all future references through that association (and that's in fact what has happened to me). We better restore the conditions back after modifying them in with_conditions method. But if your Rails app is anything complicated, it probably uses delayed loading when rendering the views, so you can not reset association conditions right away in the model. Let's delay it until the next request:

class ActiveRecord::Base
@@saved_conds = Hash.new

# dynamically modify conditions as there is no other way in Rails
# to specify run-time conditions on joins...
def self.with_conditions(assoc, conditions)
@@saved_conds[self.to_s] ||= Hash.new
# only save if that's the first call during this request
@@saved_conds[self.to_s][assoc] ||=
reflect_on_association(assoc).options[:conditions]
reflect_on_association(assoc).options[:conditions] = conditions
yield
end

# reset association conditions if any has been modified by our
# with_conditions calls in the previous request
def self.reset_conditions
return if @@saved_conds.empty?
@@saved_conds.each { |klass,associations|
associations.each { |assoc,saved|
model = klass.constantize
model.reflect_on_association(assoc).options[:conditions] = saved
}
}
@@saved_conds = Hash.new
end
end


This solution I came up with is pretty hacky - but it works. If you know of a better way to mark a class for reload in Rails, let me know. This code saves modified conditions in a hash by class and association and then you would need to add code to restore them to a mint condition in a before_ or around_ filter in ApplicationController (call like Magazine.reset_conditions). Using class name as a Hash parameter since class variable behavior in Ruby is strange to say the least - it is shared in all inherited classes and the parent class :)

Labels: , , , ,

Friday, January 25, 2008

Ruby on Rails: Dynamic Association Conditions Using Reflection

Associations between models are part of what makes Ruby on Rails framework so elegant, define Class Magazine belongs_to :publisher and Class Publisher has_many :magazines and you can simply use magazine.publisher or publisher.magazines without worrying about underlying database and object construction details. Basic associations do work for most cases, but sometimes you would need to go deeper.

Extending the Magazines example, consider that you have a third model, Reader, which has a many-to-many relationship with the Magazine model through a Subscription model:

Class Reader < ActiveRecord::Base
has_many :subscriptions
has_many :magazines, :through => :subscriptions
end

Class Subscription < ActiveRecord::Base
belongs_to :magazine
belongs_to :reader
end

Class Magazine < ActiveRecord::Base
has_many :subscriptions
has_many :readers, :through => :subscriptions
belongs_to :publisher
end

Class Publisher < ActiveRecord::Base
has_many :magazines
end

All nice and clean so far. But imagine you want to do something like output a list of publisher.magazines for a specified reader. For this, you would need to add conditions to an association. Condition for association is simply an SQL snippet which is applied to the LEFT OUTER JOIN (a database way of saying "I need all matching rows from the first table with rows from the second table if they exist") when you are doing find's on the model with :include => <association>,

# Adding condition here applies it to 
# the WHERE clause of your SELECT
# So this line selects publisher's magazines
# that specified reader is subscribed to
publisher.magazines.find(:all,
:include => :subscriptions,
:conditions => ["subscriptions.reader_id = ?",
reader.id])

# Adding a condition on association applies it
# to the left outer join
# So this code selects all magazines for the
# publisher, including only expired subscriptions
Class Magazine < ActiveRecord::Base
has_many :expired_subscriptions,
:conditions => "subscriptions.expiration <= now()"
end
publisher.magazines.find(:all,
:include => :expired_subscriptions)


However, sometimes this is not sufficient, too :) While you can dynamically specify WHERE conditions, there is no straightforward way to dynamically specify conditions for the LEFT OUTER JOIN (association conditions). So, you can't select all publisher.magazines while only loading subscriptions for a selected reader. This is needed, for example, if you want to show publisher.magazines which the current reader is subscribed to while also showing all the other magazines from this publisher with no subscription details.

There are two solutions, one is non-Railsy and involves writing find_by_sql with a full SQL query, which in this particular example would be very cumbersome. The other solution is to override the association object's conditions dynamically, shown below:

Class Magazine < ActiveRecord::Base
# The method which dynamically modifies association's
# condition. Can also be added as ActiveRecord::Base
# extension, then it will be available for all models
def self.with_conditions(assoc, conditions)
options = reflect_on_association(assoc).options
options[:conditions] = conditions
yield
end

# Finds all magazines sorting reader's magazines
# by reader's subscription date
def self.find_for_reader(reader)
with_conditions(:subscriptions,
["subscriptions.reader_id = ?", reader.id]) do
find(:all,
:include => :subscriptions,
:order => "subscriptions.expiration")
end
end
end

# And finally, this call somewhere in controller
# achieves our goal of displaying *all* publisher
# magazines sorted by selected reader's subscription
publisher.magazines.find_for_reader(current_reader)


self.with_conditions basically just adds passed conditions to the specified association, the same thing that specifying :conditions => {...} for the association does, but not limited to static predefined values. The trick is possible with reflect_on_association, a Reflection method which allows us to modify associations in runtime (as any other ruby entity could be).

NOTE: I did not find a way to save and restore association conditions back after the yield call, as restoring options[:conditions] yields the block with the last assigned conditions. So keep in mind that any association used in with_conditions call will have it's conditions changed for the duration of the current request. Fixes accepted :)

This solution is working well in production on Rails 1.2.3-1.2.6

Labels: , , ,