Blog

acts_as_union to the Rescue

Posted: Sep 07, 2009 by Billy Gray Tagged rails, keeptempo, acts_as_union, DRY

So here I am, minding my own business on Labor Day, working on the upcoming teams branch of Tempo. Basically, it introduces a new account model that’s distinct from users and projects, ideally facilitating the setup of teams for small organizations. Accordingly, an account has an owner, and every user belongs to an account. In addition, users can be managers for the whole account (new!), or users can be marked as a manager for a particular project assignment.

This all sounds great on paper, but in the Projects controller there arise a number of concerns:

  • The account owner or one of the managers should see all the account’s projects on the projects index, and also the /archive resource collection
  • All other users should only see accounts they are assigned to as managers
  • index should only show active projects and archive should only show inactive projects
  • index and archive both support an id parameter for the XML api, enabling the lookup of an aribitrary collection of managed projects

As you can imagine, this could lead to a lot of spaghetti in a controller method, that you’d want to hide away in a model. Typically, you’d want your User model to set up the various project collections as association (has_many) resources so you can provide find options as needed (such as :conditions => { :is_active => true }). That keeps your redundancy down, but your controller (or a model method) would have to conditionally decide which resource to provide. There was the possibility of adding a method to the user model that handled the conditional logic and passed back the right result set, but it would have been a royal pain to correctly pass through find options, especially given all the various circumstances above..

To make things further complicated, we need to also use these methods for the reporting interface and to make sure users only see the time entries they are supposed to see. That means keeping nonsense and code repetition to a minimum is critical.

That’s where acts_as_union saves the day!


  has_many      :managed_projects,
                  :source => :project,
                  :through => :assignments, 
                  :conditions => [ 'assignments.is_manager = ?', true],
                  :order => 'projects.name ASC'
  has_many      :owned_projects,
                  :source => :projects,
                  :through => :owned_account,
                  :order => 'projects.name ASC'
  acts_as_union :manageable_projects, [ :owned_projects, :managed_projects ]

ActsAsUnion is included in our ActsAsNetwork plugin, and it allows us to UNION together multiple associations and operate across them as we would any other association, by supporting the various find_* options we are used to with has_many.

Now I can do any of the following:


  @projects = @current_user.manageable_projects
  @projects = @current_user.manageable_projects.find(:all, :conditions => { :is_active => false})
  @projects = @current_user.manageable_projects.safe_find_from_ids(params[:id])

On a tangential note, safe_find_from_ids is a quick and dirty monkey-patch we throw in an initializer so we can look up a set of ids that returns an empty set instead of raising an exception when no ids are found:


class ActiveRecord::Base
  def self.safe_find_from_ids(*args)
    ids = args.kind_of?(Array) ? args.flatten : [args]
    ids.empty? ? [] : self.find(:all, :conditions => ["#{self.table_name}.id IN (#{ids.collect{|p| '?'}.join(',')})", ids].flatten)
  end
end

It’s not ideal, but it gets the job done. There may be a better way of doing it in recent versions of Rails, need to look into that.

Happy Labor Day!

Zetetic is the creator of the super-flexible Tempo Time Tracking system.

  • Tuesday, September 08, 2009

    Cameron Westland says:

    Instead of monkey patching safe_find_from_ids why not use the built in:

    Model#find_all_by_id(123) or Model#find_by_id(123)

    it simply returns an empty array or nil if it does not find anything!

  • Tuesday, September 08, 2009

    Cameron Westland says:

    Just thought you guys should know, acts_as_union works perfectly with Rails delegates:

    class Account < ActiveRecord::Base has_many :channels, :dependent => :destroy end

    has_one :subscription, :dependent => :destroy
    delegate :package, :to => :subscription
    delegate :channels, :to => :package, :prefix => :subscribed
    acts_as_union :available_channels, [:subscribed_channels, :channels]

    This gives me:

    account = Account.first account.channels <—local account channels account.subscribed_channels <—channels available via a subscription account.available_channels <—combination of subscription channels & local account channels

    You may want to consider writing tests for delegates to make sure it doesn’t break in the future!

  • Tuesday, September 08, 2009

    Billy Gray says:

    Hi Cameron!

    Good thinking, find_all_by_id does just the right thing, didn’t even think of that.

    Delegates are pretty interesting, and it makes sense that they still work, but I reckon we could see about adding some tests for that ;-)

  • Thursday, April 22, 2010

    Yuval says:

    I’ve been looking for something just like this, but noticed the master tree hasn’t been updated since 2008, which makes me a little uneasy. Any word on an update, or a recommended branch to follow?

  • Thursday, April 22, 2010

    Billy Gray says:

    Hi Yuval,

    We’ve been meaning to switch this over once we make our own switch to Rails 3. We’ve got no ETA for that at the moment, however, so you may want to have your own crack at it.

Add a comment