Tuesday, March 25, 2008

params[:fu] #3 ) Using fields_for and the index option to create a new parent model with multiple child models.

Alright, the last couple days were easy. Today, let's take a look at a slightly more complicated example, but one that occurs on almost every single Rails project out there: saving multiple models on one POST. Let's say your new form allows you to create a new reader and attach 3 subscriptions to it. Here's the code:

class Reader < ActiveRecord::Base
has_many :subscriptions
has_many :magazines, :through => :subscriptions
validates_associated :subscriptions

<% form_for @reader do |f| %>
<%= f.text_field :name %

<% (1..3).each do |index| %>
<% fields_for :subscriptions do |ff| %
<p><%= ff.collection_select :magazine_id, Magazine.find(:all), :id, :name, {}, :index => index %></p>
<% end %>
<% end %

<%= f.submit 'Create' %>
<% end %>


<select id="subscriptions_1_magazine_id" name="subscriptions[1][magazine_id]">
<option value="101">PC Magazine</option>
<option value="102">IT Pro<
<option value="103">WIRED</option>

<select id="subscriptions_2_magazine_id" name="subscriptions[2][magazine_id]">
<option value="101">PC Magazine</option>
<option value="102">IT Pro<
<option value="103">WIRED</option>

... (and more)

Processing ReadersController#create (for at 2008-01-14 21:12:56) [POST]
Parameters: { "commit" => "Create",
"reader" => { "name" => "stephen chu" },
"subscriptions" => { "1" => { "magazine_id" => "101" },
"2" => { "magazine_id" => "102" },
"3" => { "magazine_id" => "103" } },
"authenticity_token" => "238ba79b8282882ba01d840352616c2cc79280f0",
"action" => "create",
"controller" => "readers" }

By using the :index html option in our form builder generated fields, we are essentially inserting a unique index key to the posted value of that field. Why should we care? Well, here is how the controller code would look like:

def create
@reader = Reader.new params[:reader]
@subscriptions = @reader.subscriptions.build params[:subscriptions].values
if @reader.save
flash[:success] = 'Good.'
flash[:error] = 'Bad.'

I just persisted a reader with multiple subscriptions, and the notable differences I added in the create action, was these characters: params[:subscriptions].values. Nothing much changed from the last has_one :computer example besides association related differences. There is no looping, map/collect-ing, gsub-ing, etc., in my action while creating and attaching these multiple subscriptions onto the new reader. The controller action is just doing its same-old routine: receives posted parameters, and shove them into the corresponding models. So how is this done?

Looking at the source of the #build method on the association proxy classes (e.g. HasManyAssociation), you will notice something interesting:

def build(attributes = {})
if attributes.is_a?(Array)
attributes.collect { |attr| build(attr) }
build_record(attributes) { |record| set_belongs_to_association_for(record) }

The method recognizes array! If you pass in an array of hashes, it will process them one by one! So how do we get an array of hashes? In our case the way to get array of hashes is by calling Hash#values, because they are organized in sub-hashes. Calling .values will yield us the following array:

params[:subscriptions].values  # => [ { "magazine_id" => "101" }, { "magazine_id" => "102" }, ... ]

By organizing your params on your view in ways that they can be directly consumed by your models, you end up with a lot less code to write. No more bastardizing your action code!

(back to the TOC of the params[:fu] series)


MDWeezer said...

Fantastic post. As simple of a topic and a task that seems to be applicable in so many areas, this is one of the best described posts describing how to handle it.

Thanks again.

Maxim Kulkin said...

Good post.

One thing: you can pass :index option to #fields_for method, instead of passing it to every field. #field_for pass all it's options to every form tag helper you invoke on it.

Eric Anderson said...

This blog article showed me something new that I didn't already know. For that I thank you.

austinfromboston said...

Great article. @Maxim - i tried adding an :index option to fields_for. no love in rails 2.0.2. is this an edge feature?

Anonymous said...

Thank you! This helped.

Anonymous said...

Awesome post! Persisting child objects has frustrated me for a long time. Never knew this feature existed in Rails. Cheers!

Hatem said...

Great idea! But I have 2 questions:
1) How can I add "Add Subscription" link to add a new raw with empty subscription fields? I tried to do it but I couldn't assign the correct index.
2) How to validate fields of each subscription and display its error message besides it?

A1aks said...

This Post solved my headache from 3 weeks...
Thanks Stephan.. Ur the Ultimatum...


Anonymous said...

Hatem 2nd question => +1