onrails.org home

Making CRUD less "Cruddy", one step at a time

One of the great “new” features of Rails (as of 2.3) is accepts_nested_attributes_for, allowing you to build cross-model CRUD forms without “cruddying” your controller. There are some great examples out there about how to do this, but I’d like to walk thorough a particular use case — managing the “join” records in a has_many :through relationship.

Consider the following database schema:

class Villain < ActiveRecord::Base
has_many :gifts
has_many :super_powers, :through => :gifts

class Gift < ActiveRecord::Base
belongs_to :villain
belongs_to :super_power

validates_uniqueness_of :super_power, :scope => :villain_id


class SuperPower < ActiveRecord::Base
has_many :gifts
has_many :villains, :through => :gifts

In our dataset, there are a relatively small number of super powers which we wish to present as a list of checkboxes on the villain management form. Checking/unchecking the boxes will manage the gift records for that villain, effectively managing the list of super powers available to the baddy.

To get started, we need to add accepts_nested_attributes_for :gifts to the Villain class — piece of cake. To complete the implementation, we need to change the params hash that our form generates. Let’s review the cases that we need to support and the associated params hash format needed to implement the correct functionality.

The first case is a super power record that is not currently associated with the villain. Here, the UI should display an unchecked checkbox. If we check it and submit the form, a gift record should be created linking the villain with the super power, making this bad guy that much badder. Here is an example of the params hash we should be sending to accomplish this:

{ ‘villain’ => { ‘name’ => ‘Lex Luthor’, … ‘gifts_attributes’ => { 1 => { ‘super_power_id’ => 5 }, 2 => { ‘super_power_id’ => 7 }, … } } }

The alternate case is a super power this villain already possesses. In this instance, the UI should display a checked checkbox, and if we uncheck it, the existing gift record should be deleted, diminishing the villain’s capacity for evil. And our params hash needs to look like:

{ ‘villain’ => { ‘name’ => ‘Two-Face’, … ‘gifts_attributes’ => { 1 => { ‘id’ => 101, ‘_delete’ => true }, … } } }

Note that the keys for the gifts_attributes hash are arbitrary; we can use any scheme to generate unique keys for the hash.

So how can we craft a form that sends the params hash that Rails wants to see? Here’s my implementation:

<%- SuperPower.all.each_with_index do |super_power, index| -%>
<%- end -%>

If the gift is detected, the villain has the super power, and we handle our second case from above, using the checkbox / hidden field hack Rails employs in the check_box helper method to make sure a value is sent whether or not the checkbox is checked. The else block handles the other case, setting up our params hash to create the gift if the checkbox is checked.

This works, but we probably don’t want to copy and paste that code everywhere we use this pattern. How can we reuse this in a DRY fashion? Here’s a helper method that encapsulates the logic:

def has_join_relationship(model, join_collection_name, related_item, collection_index, options={}) returning "" do |output| relationship_name = options[:relationship_name] || related_item.class.table_name.singularize + “_id” tag_prefix = “#{ model.class.class_name.underscore }[#{ join_collection_name }_attributes][#{ collection_index }]” if join_item = model.send(join_collection_name).find(:first, :conditions => { relationship_name => related_item.id }) output << hidden_field_tag(“#{ tag_prefix }[id]”, join_item.id) output << hidden_field_tag(“#{ tag_prefix }[_delete]”, true) output << check_box_tag(“#{ tag_prefix }[_delete]”, false, true) else output << check_box_tag(“#{ tag_prefix }[#{ relationship_name }]”, related_item.id, false) end end end

Drop that in a helper, and then your form code becomes:

<%- SuperPower.all.each_with_index do |super_power, index| -%>
<%- end -%>

Much nicer … although I’m not sold on the name has_join_relationship. Any suggestions?

UPDATE: There was a problem with the published code, which I corrected — sorry about that.

RAILS 3 UPDATE: To get the code above to work with Rails 3 / Ruby 1.9.2, I had to change class.class_name to class.name, and call .html_safe on the initial string so that it would not get escaped.

Fork me on GitHub