Ye Olde
Talbott Blog

Safely Saving Relationships (in Rails)

We’re talking Rails relationships here – if you were looking for marriage counseling, I’m afraid you’ve come to the wrong place. Duane Johnson was struggling with something the other day that I think everybody hits once they’re using ActiveRecord in anger – how do you validate has_and_belongs_to_many associations without saving them out to the database first?

I hit this problem a few weeks ago, and like Duane, it took me a while to find a solution. Basically, I had a bundle of items, like so:

class Item < ActiveRecord::Base
end

class BundleItem < ActiveRecord::Base
belongs_to :item
belongs_to :bundle
end

class Bundle < Item
has_many :bundle_items
end

While I’m not using an actual has_and_belongs_to_many, that is what I effectively have. Using these models, this action works great for creating a new bundle:

def create
@bundle = Bundle.new(params[:bundle])
if request.post?
@bundle_items = (params[:bundle_items] || []).collect do |item_id, quantity|
BundleItem.new(:item => Item.find(item_id), :quantity => quantity)
end
@bundle.bundle_items = @bundle_items
if @bundle.save
flash[:notice] = “Bundle created.”
redirect_to :action => ‘index’
end
end
end

As Duane noted, this works because the bundle is a new record, so ActiveRecord properly defers the necessary relationship building to a callback. However, if the bundle already exists, assigning the bundle items to the bundle will actually create them at assignment:

def edit
@bundle = Bundle.find(params[:id])
@bundle.attributes = params[:bundle]
@bundle_items = (params[:bundle_items] || []).collect do |item_id, quantity|
BundleItem.new(:item => Item.find(item_id), :quantity => quantity)
end
if request.post?
@bundle.bundle_items = @bundle_items # The bundle items get saved here…
if @bundle.save # which means if this fails, we’re hosed.
flash[:notice] = “Bundle edited.”
redirect_to :action => ‘index’
end
end
end

Duane got around this by tricking ActiveRecord in to thinking that the record that was being edited was a new record, but messing with internals like that causes me concern due to future compatibility issues and unintended side-effects. Instead of tricking Rails in to doing what we want, why not just do it ourselves?

class Bundle < Item
has_many :bundle_items

alias old_bundle_items bundle_items alias old_bundle_items= bundle_items= attr_writer :bundle_items def bundle_items @bundle_items ||= old_bundle_items.dup end before_save do |bundle| if bundle.old_bundle_items != bundle.bundle_items BundleItem.delete(bundle.old_bundle_items.collect{|e| e.id}) bundle.bundle_items.each do |bundle_item| bundle.old_bundle_items << bundle_item end end end

end

Now the edit action we wrote will work like a charm, with the relationships only being saved after everything validates correctly.

So just remember kids, ActiveRecord callbacks are our friends! And don’t forget to let me know if it works for you in the comments or via email.