Bi-Directional has_and_belongs_to_many on a Single Model in Rails

May 04, 2015 Ruby on Rails

Hold on to your butts, because this ain't simple.

Why It's Complicated

What we're trying to accomplish is to associate a record within one Rails model to many other records in the same model, while also being able to allow that record to be associated to many other records within the same model.

Phew!

I've talked about a has_many relationship within a single model, but this is far more complex. This isn't your typical parent/child relationship. In this case, you don't really care which object is the parent or which one is the child. You just want to grab an object and get all of its associated records, and there may be no logical order to that.

And your typical has_many :through and has_and_belongs_to_many are much simpler because the names associating one record to another are different and predictable, which they aren't in this case.

The Example

Our example is that we have a Page model, and any page can have and belong to many other pages, with no logical hierarchical ordering.

The JOIN Model

First thing is first, we still need a model to store the associations (you could use the Page model for everything, but that's not very Railsy).

So, let's create a PageAssociation model. The key here is that the name's of our two attributes are essentially irrelevant.

$ bundle exec rails g model PageAssociation left_page_id:integer right_page_id:integer

Why left_page_id and right_page_id? I have no idea. Why not batman_id and robin_id? It doesn't matter. Just create your convention and know what they are.

Associations

After you create the model, add your associations. As you usually would in a has_many, :through relationship, this JOIN model has two belongs_to columns. The difference here is we have to specify the class name so Rails knows what to do.

app/models/page_association.rb

class PageAssociation < ActiveRecord::Base

  belongs_to :left_page, :class_name => 'Page'
  belongs_to :right_page, :class_name => 'Page'

end

The Page Model

The Page model is much weirder. We are first going to define has_many and has_many, :through associations in both directions.

So, left first.

app/models/page.rb

class Page < ActiveRecord::Base

  has_many :left_page_associations, :foreign_key => :left_page_id,
           :class_name => 'PageAssociation'
  has_many :left_associations, :through => :left_page_associations,
           :source => :right_page

end

These two associations allow us to get from a page designated as right_page_id in the JOIN model, to all of its left_page_ids, which are Page objects.

Then, add the reverse to it.

app/models/page.rb

class Page < ActiveRecord::Base

  has_many :left_page_associations, :foreign_key => :left_page_id,
           :class_name => 'PageAssociation'
  has_many :left_associations, :through => :left_page_associations,
           :source => :right_page
  has_many :right_page_associations, :foreign_key => :right_page_id,
           :class_name => 'PageAssociation'
  has_many :right_associations, :through => :right_page_associations,
           :source => :left_page

end

That kind of makes sense, right? It's a little abstract because the naming isn't as semantic as we're used to with Rails, but the conventions are the same.

The problem we have here is that the association needs to be bi-directional. This means we assume that accessing a pages left_associations leaves us with missing associated records, as it ignores right_associations.

It's not the most efficient, but I've solved this by simply combining the two in an instance method.

app/models/page.rb

class Page < ActiveRecord::Base

  has_many :left_page_associations, :foreign_key => :left_page_id,
           :class_name => 'PageAssociation'
  has_many :left_associations, :through => :left_page_associations,
           :source => :right_page
  has_many :right_page_associations, :foreign_key => :right_page_id,
           :class_name => 'PageAssociation'
  has_many :right_associations, :through => :right_page_associations,
           :source => :left_page

  def associations
    (left_associations + right_associations).flatten.uniq
  end

end

Now I can call page.associations and get all of its associations (assuming page is a Page object).

The Form

I hope you're still following. The last bit is crucial. The form and the controller.

First, note my assumption here is that we have a form for a particular page, and that's where other pages can be associated to it. We assume that every page has one of these forms.

So, the issue is that if we just use, say, left_association_id in the form, that we're allowing Page A to be left-associated with Page B, but not accounting for Page B to be right-associated with Page A, right? Right. Well, we're going to do it anyways, and we'll get around this quandary.

I'm using simple_form lingo here. I highly encourage you to check it out. I'm also making up a location for this view file. It doesn't have to be where I put it.

app/views/pages/_form.html.erb

<%= f.association :left_associations, :label => 'Linked Pages',
                  :as => :check_boxes, :collection => Page.all - [@page],
                  :checked => @page.associations.collect(&:id) %>

Here are the items to note:

And last, and most important, notice we are checking all page associations (:checked => @page.associations.collect(&:id)).

This is our workaround. If you are on Page A's form and it is right-associated with Page B, then we wouldn't see this association. So, we manually check the checkbox for Page B.

And if you're thinking, That's going to duplicate an association between Page A and Page B, then you are absolutely correct.

There are two things that remedy this. The first we already know about. In the Page model, when we combined our associations into one method, notice we ran flatten and then uniq on that array of objects. uniq is what gets rid of any duplicated associations.

Second, since we might have duplicated records, we need to be sure we delete records when an association is removed. This is the trickiest part.

I'm going to do this in the pages controller. In theory you could handle it with an after_destroy callback on the PageAssociation model, but I've always had issues with after_destroy, so I'm not going to mess with that.

Again, your controller may be elsewhere. And this time I've commented the code, since there's a lot going on.

app/controllers/pages_controller.rb

class PagesController < ApplicationController

  def update
    # Get the page
    @page = Page.find_by_id(params[:id])
    # First, we find the difference between the associations
    # BEFORE the form was filled out, and what is being
    # submitted.
    deleted_ass_ids = (
      @page.associations.collect(&:id) -
      params[:page][:left_association_ids].reject(&:blank?).map(&:to_i)
    )
    if @page.update(update_params)
      # AFTER we update the page, destroy all the records
      # that differ from before and after the form was
      # submitted.
      deleted_ass_ids.each do |ass_id|
        ids = [@page.id, ass_id]
        TemplateAssociation.where(
          :left_template_id => ids,
          :right_template_id => ids
        ).destroy_all
      end
      redirect_to(@page, :notice => 'Page saved!')
    else
      render 'edit'
    end
  end

  private

    def update_params
      params.require(:page).permit(:left_association_ids => [])
    end

end

One thing to notice is the update_params only show the left_association_ids param for demonstration purposes.


That's all! I hope you've followed along and get it working. If you have any corrections or suggestions, let's talk.

Did you learn something or find this article interesting?

If so, why not