Virtual Attributes for Many-to-Many Checkboxes in Ruby on Rails
by Jarrett Colby
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.