Virtual Attributes for Many-to-Many Checkboxes in Ruby on Rails

by Jarrett Colby

Checkboxes are an intuitive way to control many-to-many relationships. There are several implementations floating around the Web, but none that I've seen works with form_for or fields_for. In this article, I explain how virtual attributes and method_missing make it possible for a form_for to transparently control a many-to-many relationship.

Imagine a community application where each User can belong to multiple Groups. Since has_and_belongs_to_many is deprecated in favor of has_many :through, you'd probably create a Membership model to link them up. Then you could have an "Edit User" screen where you can assign Users to Groups with checkboxes.

There are two challenges you'll face implementing this: 1) populating the checkboxes when the view is rendered (i.e. the correct ones should be checked), and 2) creating and destroying the appropriate Membership objects after the form is submitted.

Ideally, you'd be able to use the elegant and convenient form_for to solve both those problems. It might look something like this:

<% form_for :user do |f| %>
        <!-- Group.find could also go in the controller. -->
        <% Group.find(:all).each do |group| %>
                <p>
                        <%= f.check_box "member_of_group_#{group.id}" %>
                </p>
        <% end %>
<% end %>

This way, the right checkboxes will be checked when the page renders, and the right data will be submitted in params[:user].

But form_for requires that each input be linked to one model attribute. In the example above, it's looking for attributes called member_of_1, member_of_2, et cetera. Those attributes don't exist yet, so we'll have to create them. To do that, we need method_missing and respond_to?.

class User < ActiveRecord::Base
        has_many :memberships
        has_many :group, :through => :memberships
        
        def method_missing(meth, *args)
                if match = meth.to_s.match(/^member_of_group_([0-9]+)$/)
                        # It's a call to a reader
                        return memberships.exists?(:group_id => match[1])
                elsif match = meth.to_s.match(/^member_of_group_([0-9]+)=$/)
                        # It's a call to a writer
                        existing = memberships.find(:first, :conditions => {:group_id => match[1]})
                        # args[0] would be the value given by update_attributes.
                        # It will usually be '1', but for completeness, we also
                        # check for true and 1.
                        if [true, 1, '1'].include?(args[0])
                                # member_of_groups_x was set to true
                                if existing.nil?
                                        # The Membership object does not exist yet,
                                        # so we'll create it.
                                        memberships.create!(:group_id => match[1])
                                end
                        else
                                # member_of_groups_x was NOT set to true
                                unless existing.nil?
                                        # The membership object exists,
                                        # so we must delete it.
                                        Membership.destroy(existing.id)
                                end
                        end
                else
                        # Without this, we'd miss our useful NoMethodErrors
                        super
                end
        end
        
        # Some methods, like update_attributes, use
        # respond_to? to check if an attribute exists. If
        # you don' define respond_to?, those methods won't work.
        def respond_to?(meth)
                if meth.to_s.match(/^member_of_group_([0-9]+)=?$/)
                        true
                else
                        super
                end
        end
end

The code looks daunting, but it's very simple conceptually. Here's the equivalent pseudo-code:

if method name is like member_of_group_#
        return true if the Membership exists, false if not
else if method name is like member_of_group_#=
        if the argument is true
                create a Membership if it doesn't already exist
        else
                destroy the Membership if it exists

In case you're wondering how the controller works, it's very simple. The controller need not even be aware that it's updating a many-to-many relationship:

# in UsersController#update
@user.update_attributes params[:user]

update_attributes works because our form_form sent a hash of attributes, and method_missing will handle them. We've factored everything weird into our model, and our view and controller get to pretend like there's no many-to-many fanciness happening at all. Plus, we get to look cool using method_missing.

If you use this approach often, you may want to factor it out into some kind of declarative class method. I'd love to see any code alongs those lines.

For an alternate approach to many-to-many checkboxes, have a look at Paul Barry's article.