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