Aliasing Associations in Rails 4

06 May 2014

TL;DR

You can use ActiveRecord's alias_attribute method on associations.

The set up

In an inventory system I'm working on, pallets can be assigned to various entities, such as a sales person or a sales order. Originally, they could only be reserved to a sales order, and so there's just a column in the pallets table to tie it back to a sales order record. But now, we have four such columns, and there is talk of adding several new kinds of holds. Time for a refactor!

The refactor

Holds are now stored using STI in a holds table.
app/models/hold.rb

class Hold < ActiveRecord::Base
  belongs_to :pallet
  ...
end

app/models/holds/sales_order.rb
class Holds::SalesOrder < Hold
  ...
end

There are types set up for each kind of hold a pallet can have, and they each have their own logic. For example, a will call fulfillment transaction can remove a number of boxes from a pallet. What if the pallet has 80 boxes, and there's an existing order that pulls 60 boxes, and the new will call order calls for 30 more? Obviously, that pallet shouldn't be used to fulfill on the order, because it doesn't have enough boxes. That kind of logic used to live in Pallet, but now it can reside more tidily in the Holds::WillCallFulfillment class.

A note on STI

To be able to store the STI holds classes in their own directory, I had to add the directory to the autoload_paths configuration.
config/application.rb

  config.autoload_paths << File.join(Rails.root, 'app', 'models', 'holds')

Finally coming around to the point

I want to be able to access the "holder" object for any hold, always via the identical attribute "holder" - but sometimes it will return a sales order, sometimes a sales rep, etc.

# => returns an instance of the holding record, be it a SalesOrder, SalesRep, etc.
any_hold.holder

# => returns an instance of a SalesOrder record
any_sales_order_hold.holder
any_sales_order_hold.sales_order 

# => returns an instance of a SalesRep record
any_sales_rep_hold.holder
any_sales_rep_hold.sales_rep

I want to be able to access the underlying id of the holder, stored as hold_id, by calling either Hold#hold_id or Hold#sap_sales_order_no on an instance of any derived class of Hold. Note: because of other concerns, I opted to tie to the sap_sales_order_no attribute rather than the id.

Holds::SalesOrder.first.hold_id            # => 1633
Holds::SalesOrder.first.sap_sales_order_no # => 1633

I want all the active record getter and setter methods for both the holder and the sales_order, sales_rep, etc. associations to work.

sales_order_hold = Holds::SalesOrder.new
sales_order_hold.holder = SalesOrder.find(1633)
sales_order_hold.save

sales_order_hold = Holds::SalesOrder.new
sales_order_hold.sales_order = SalesOrder.find(1633)
sales_order_hold.save

The solution

It turns out that alias_attribute works with associations, too!

class Holds::SalesOrder < Hold

  belongs_to :sales_order, :class_name => "::SalesOrder", :primary_key => :sap_sales_order_no, :foreign_key => :hold_id

  alias_attribute :sap_sales_order_no, :hold_id # this one is obvious
  alias_attribute :holder, :sales_order         # here's our "alias_association"

  ...
end

Conclusion

At first, I was mucking around with metaprogramming to make my own method, alias_association, to do this job for me. The realization that alias_attribute should work here was a ray of light.