HomeRamblings  ⁄  ProgrammingRuby LanguageCSS

Rails has and belongs to many (habtm) demystified

Published: October 27, 2008 (about 6 years ago)
Updated: about 5 years ago
Every time I have to implement a many-to-many relationship between Rails models, I seem to have to figure out how to do it effectively all over again. Especially as Rails seems to evolve the relational hooks with better support and elegance. Here, I will show a has_and_belongs_to_many strategy that works well for me. Along the way, I'll expose a few other minor tricks, such as adding a custom inflector for pluralizing your model or not adding the ID column on a table declaration. The following Browser Edit form is what we're going for. That is, having a list of Operating Systems to check off while editing a Browser object:
Editing Browser View
First, the models: What I wanted, was a way to declare browsers (Firefox, Explorer, Opera, etc.) and associate them with one or more operating systems (OS X, Windows, Linux, etc.). This begats two models, Os, and Browser with a many-to-many table, browsers_oses joining the two. The migration scripts for these models follow:
class CreateBrowsers < ActiveRecord::Migration
  def self.up
    create_table :browsers do |t|
      t.string :name
      t.string :version
      t.string :short_name
      t.timestamps
    end
  end

  def self.down
    drop_table :browsers
  end
end
class CreateOses < ActiveRecord::Migration
  def self.up
    create_table :oses do |t|
      t.string :vendor
      t.string :name
      t.string :version
      t.string :short_name
      t.timestamps
    end
  end

  def self.down
    drop_table :oses
  end
end
The Browser and Os table are very straightforward migration scripts. However, pay attention to the next script as we do a couple of interesting things. A) we tell the migration not to create the default ID column and B) we use the t.references declaration in defining our columns.
class BrowsersOses < ActiveRecord::Migration
  def self.up
      create_table :browsers_oses, :id => false do |t|
        t.references :browser
        t.references :os
        t.timestamps
      end
    end

    def self.down
      drop_table :browsers_oses
  end
end
Note that I used the singular version of the model names. I originally used the plural form and got browsers_id and oses_id instead of the expected browser_id and os_id column names. Which brings me to another issue I encountered. "Os" didn't pluralize properly, so I needed to add a custom inflector to the project's config/initializers/inflections.rb file:
ActiveSupport::Inflector.inflections do |inflect|
  inflect.irregular 'os', 'oses'
end
For simplicity and clarity, I chose the irregular form since I didn't figure on needing to match regular expressions against more complex model names. Since there are many articles dedicated to the model side of things where relationships are concerned, I will skimp on discussing model implementations. Please see many-to-many-dance-off by Josh Susser or the has_and_belongs_to_many page on the Ruby on Rails wiki site for a couple of classics on the topic. The models are straightforward, as shown below. Note that the browsers_oses table does not get its own model. Rails simply expects the table to be a concatenation of the two models pluralized and the model names in alphabetical order.
class Os < ActiveRecord::Base
  has_and_belongs_to_many :browsers
end

class Browser < ActiveRecord::Base
  has_and_belongs_to_many :oses
end
I'm not a big fan of the default views the rails scaffolding produces, but I do tend to run script/generate scaffold "model_name" to get the ball rolling quickly, anyway. So fire up a couple of views with:
script/generate scaffold browser
script/generate scaffold os
Note: I removed all previously generated files and regenerated the Os model and views after adding the irregular plural inflection rule described above. Once I get my models and views, the first thing I do is create a form partial for each: app/views/oses/_form.html.erb
<fieldset>
	<legend>OS</legend>
	<p>
		<label for="vendor">Vendor</label>	
		<%= text_field :os, :vendor, :value => @os.vendor %>
	</p>
	
	<p>
		<label for="name">Name</label>	
		<%= text_field :os, :name, :value => @os.name %>
	</p>
	
	<p>
		<label for="version">Version</label>
		<%= text_field :os, :version, :value => @os.version %>
	</p>

	<p>
		<label for="short_name">Short Name</label>
		<%= text_field :os, :short_name, :value => @os.short_name %>
	</p>
</fieldset>
There's nothing fancy about the Os form as there's no references to select drop-downs or the likes. With the above wrapped in a fieldset with legend of Os. I then tweak app/views/oses/edit.html.erb like so:
<h1>Editing OS</h1>

<% form_for(@os) do |f| %>
  	<%= f.error_messages %>
	<%= render :partial => 'form' %>
  
	<fieldset>
    	<%= f.submit "Update" %>
		<%= link_to 'Show', @os %> |
		<%= link_to 'Back', oses_path %>
  	</fieldset>
<% end %>
And similarly for app/views/oses/new.html.erb:
<h1>New OS</h1>

<% form_for(@os) do |f| %>
  	<%= f.error_messages %>
	<%= render :partial => 'form' %>

  	<fieldset>
    	<%= f.submit "Create" %>
		<%= link_to 'Back', oses_path %>
  	</fieldset>
<% end %>
app/views/oses/show.html.erb is nearly a cut-n-paste of the _form.html.erb file with the input tags stripped out:
<fieldset>
	<legend>OS</legend>
	<p>
		<label for="vendor">Vendor</label>	
		<%= @os.vendor %>
	</p>
	
	<p>
		<label for="name">Name</label>	
		<%= @os.name %>
	</p>
	
	<p>
		<label for="version">Version</label>
		<%= @os.version %>
	</p>

	<p>
		<label for="short_name">Short Name</label>
		<%= @os.short_name %>
	</p>
</fieldset>

<fieldset>
	<%= link_to 'Edit', edit_os_path(@os) %> |
	<%= link_to 'Back', oses_path %>
</fieldset>
It would be nice if the tag fields were action aware and intelligently generated inputs or text rendering according to whether new, edit, or show was the action, wouldn't it?? The OsesController model is unchanged from the generated scaffolding code, so I won't list its code here. Lets hop over to Browsers model and view as this is the model I do the most interesting things with. I will be rendering the Browser inputs so that one of the input is a checkbox list of all available OSes. To do this, I am effectively performing a "find all" on the Os model, so I DRY my code by adding the following method to the BrowsersController:
def get_all_oses
  @oses = Os.find(:all, :order => 'vendor, version')
end
Declaring small, single purpose methods is a good practice to be in the habit of. As it happened, I decided at some point to order the checklist by vendor and version. Instead of having to change code in four or five methods, I only had to update the one get_all_oses method. A tad bit of extra work up front pays off! Similarly, compound names get their own method in the model class so I have ability to easily change without hunting down references everywhere in the view. So the Os model presented above becomes:
class Os < ActiveRecord::Base
  has_and_belongs_to_many :browsers
  
  def display_name
    "#{vendor} #{name} #{version}"
  end
end
And then I reference like so in the Browser view's form partial:
<fieldset>
	<legend>Browser</legend>
	<p>
		<label for="name">Name</label>
		<%= text_field :browser, :name, :value => @browser.name %>
	</p>
	
	<p>
		<label for="version">Version</label>
		<%= text_field :browser, :version, :value => @browser.version %>
	</p>

	<p>
		<label for="oses">Operating Systems</label>
		<% @oses.each do |os| %>
			<%= check_box_tag(
                            "browser[os_list][#{os.id}]", 
                            "1", 
                            @browser.oses.detect{|d| d == os}) %> 
			<%= "#{os.display_name}"%><br />
		<% end %>
	</p>

	<p>
		<label for="short_name">Short Name</label>
		<%= text_field :browser, :short_name, :value => @browser.short_name %>
	</p>
</fieldset>
A few important things to note here about the Operating Systems checklist:
  • the @oses variable is a list of all Oses as per the get_all_oses method.
  • I gave the checkbox name "browser[os_list][#{os.id}]" which causes the params to collect all checkboxes into an "os_list" hash that is contained in the browser keyword on the params hash. Keep this in mind because we're going to leverage this in a moment.
  • I don't do anything with the values of the checkbox, so the value assigned ("1" in this case) is of little concern.
  • The @browsers.oses.detect{|d| d == os} line activates the checkbox if the browser already has the Os associated with it. (quite important for edit actions).
So the Operating Systems checklist is simply an iteration over all OSes, emitting a checkbox input control for each and setting the checked flag accordingly. So how do we handle this in the controller? We don't! That's right. Not in the controller, but in the model. This is "The Rails Way" as described in the Skinny Controller, Fat Model approaches championed by Jamis Buck and The Rails Way crew. My approach is to add a attribute accessor called "os_list" to the Browser model and a callback to the after_save event. So the Browser model now becomes:
class Browser < ActiveRecord::Base
  has_and_belongs_to_many :oses

  attr_accessor :os_list
  after_save :update_oses

  private 
  
  def update_oses
    oses.delete_all
    selected_oses = os_list.nil? ? [] : os_list.keys.collect{|id| Os.find_by_id(id)}
    selected_oses.each {|os| self.oses << os}
  end
end
So there's a bit of magic! The update_attributes and save methods will assign the os_list hash from the params to our accessor "os_list" property automatically and thus provide us convenient access to the hash data in our callback handler. This is Rails at its best. Paul Barry gets the credit for being the first to put it together (at least for me). What this method does is first clear out all the currently associated Oses. Then, if there are no selected Oses (in which case, items will be []), nothing happens with the loop, otherwise, each item is pushed onto the browser.oses list. When I first started writing Ruby on Rails apps, I was a stickler for not hitting the database unnecessarily and I would write lots of conditionals to avoid doing so. However, the number of bugs I found in such logic eventually led me down the path of aggressively reducing lines of code in situations where it simply does not matter. Here, we're talking about, at most, about 10 operating systems defined in the app I'm writing and I am very infrequently going to be adding new browsers to the list (about as often as a major version comes out as a rule of thumb). Is it really worth the extra code and the extra specs (you are writing test cases, aren't you?) to gain so little? Rails is smart. It'll issue a delete statement only if there are actually Oses in the list and it'll only issue one DELETE statement, too! Check this session history out (for PostgreSQL):
  SQL (0.000153)   BEGIN
  SQL (0.000337)   DELETE FROM "browsers_oses" WHERE browser_id = 14 AND os_id IN (8,9)
  SQL (0.009701)   COMMIT
  SQL (0.000131)   BEGIN
  SQL (0.000194)   INSERT INTO "browsers_oses" ("browser_id", "updated_at", "os_id", "created_at") VALUES (14, '2008-10-24 19:33:43.107494', 8, '2008-10-24 19:33:43.107494')
  SQL (0.000649)   COMMIT
  SQL (0.000094)   BEGIN
  SQL (0.000191)   INSERT INTO "browsers_oses" ("browser_id", "updated_at", "os_id", "created_at") VALUES (14, '2008-10-24 19:34:01.088552', 9, '2008-10-24 19:34:01.088552')
  SQL (0.000570)   COMMIT
  SQL (0.000086)   BEGIN
  SQL (0.000199)   INSERT INTO "browsers_oses" ("browser_id", "updated_at", "os_id", "created_at") VALUES (14, '2008-10-23 20:24:59.809242', 2, '2008-10-23 20:24:59.809242')
  SQL (0.000657)   COMMIT
  SQL (0.000076)   BEGIN
  SQL (0.000110)   COMMIT
Redirected to http://localhost:3000/browsers/14
Completed in 0.08188 (12 reqs/sec) | DB: 0.01585 (19%) | 302 Found [http://localhost/browsers/14]
Now, if you've got a situation where 100's of users are performing a similar action and the small doses truly add up to something significant in your daily logs, then definitely optimize. Just be cognizant of your use-case and profile your application for performance before getting fancy with the conditional updates, otherwise you're in the trap of premature optimization and not getting something else done you might have wish you were! Ok, having digressed a bit, lets get back on course. So what does the Browser Controller now look like? Well, the only thing I have to add is a call to "get_all_oses" just in case of a failure to save and the redirect back to the same page occurs. So this is what we have:
def get_all_oses  
  @oses = Os.find(:all, :order => 'vendor, version')  
end  

# POST /browsers
# POST /browsers.xml
def create
  @browser = Browser.new(params[:browser])
  get_all_oses

  respond_to do |format|
    if @browser.save
      flash[:notice] = 'Browser was successfully created.'
      format.html { redirect_to(@browser) }
      format.xml  { render :xml => @browser, :status => :created, :location => @browser }
    else 
      format.html { render :action => "new" }
      format.xml  { render :xml => @browser.errors, :status => :unprocessable_entity }
    end
  end
end

# PUT /browsers/1
# PUT /browsers/1.xml
def update
  params[:oses] ||= {}
  @browser = Browser.find(params[:id])
  get_all_oses

  respond_to do |format|
    if @browser.update_attributes(params[:browser])
      flash[:notice] = 'Browser was successfully updated.'
      format.html { redirect_to(@browser) }
      format.xml  { head :ok }
    else
      format.html { render :action => "edit" }
      format.xml  { render :xml => @browser.errors, :status => :unprocessable_entity }
    end
  end
end
One last trick for the eagle eyes: The form image I posted positions the labels in bold above the inputs and with a trailing colon, but nary a <BR> element (or colons) to be seen in the views. I accomplished this with simple CSS styling by switching the label element to a block element (rather than inline as is the default) and then providing the colon as additional content:
label {
	display: block;
	font-weight: bold;
}
label:after {
	content: ':';
}
So there you have it. Leveraging the Rails framework appropriately, you can easily provide a checklist generated from one model on another's edit form and have those records maintained in a many-to-many relationship without bloating your views or controllers.
comments powered by Disqus

Web Analytics