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.