Home

Bi-Directional has_and_belongs_to_many on a Single Model in Rails

Bi-directional HABTM relationships are easy in Rails, but when you need to do it on a single model, that's when it gets tricky. Here's one approach.

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:

  • f.association is a SimpleForm method. You will have to research how to accomplish this with Rails' default helpers if you don't want to use SimpleForm (I encourage you to try it).
  • Page.all - [@page] is not an efficient way to create the collection, but it demonstrates the logic I use to build the collection (the collection is the set of objects available for association)
  • @page is a Page object.

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.

Let's Connect

Keep Reading

A has_many Relationship within a Single Model in Rails

Here are a couple methods for dealing with uni-directional many-to-many associations in Rails.

Apr 04, 2015

Rails on Heroku - Redirect Root Domain to www

How to use a "www" subdomain as your primary domain on a Rails app hosted with Heroku.

Jun 05, 2020

How To Use Paper Trail As An Activity Feed

That whole killing two birds with one stone approach might just work in using PaperTrail as an activity log, at least in simpler projects.

Jun 15, 2015