Recently a project I was working on needed a many-to-many relationship that would also store some extra data in the pivot table.
Rails provides helpers to make working with this sort of relationship a breeze but when you start to include the nested forms and requirement to add data to that connecting table the solution may not be that obvious.
I’ll be using Rails 4, the code will be the same for Rails 3.2 for the most part the major difference is Strong Parameters is now used in place of
attr_accessible. You can find out how to install Rails 4 yourself here.
For this example I’m going to use a Survey application, unfortunately this was a survey done in the street on paper and now the results need to be manually added to the system.
Each Survey will have some Questions, these Questions will be answered by a Participant.
So in this example we need an Answers table to be our many-to-many table that will link our Participant to our Question and keep the Answer the participant provided in an additional column.
So let’s start a new Rails application.
rails new SurveyApp
Generate some models and scaffolds to save a little bit of typing later.
rails generate scaffold Participant name rails generate scaffold Survey name rails generate model Question content:text survey:references rails generate model Answer question:references participant:references content:text rake db:migrate
First, we’ll sort out the models, the file names are above each class as a comment.
# app/models/participant.rb class Participant < ActiveRecord::Base has_many :answers has_many :questions, through: :answers end # app/models/survey.rb class Survey < ActiveRecord::Base has_many :questions accepts_nested_attributes_for :questions end # app/models/question.rb class Question < ActiveRecord::Base belongs_to :survey has_many :answers has_many :participants, through: :answers accepts_nested_attributes_for :answers end class Answer < ActiveRecord::Base belongs_to :participant belongs_to :question end
You’ll notice I’m not worrying about validation in this guide because it’s a simple enough example and this post is concentrating on the nested forms and many-to-many associations.
You should be familiar with what you see here, I’ve used
through: as this is recommended in the documentation as we have extra fields we want to access on the pivot table.
Now let’s tackle the controllers, in fact we only need to tackle the Survey controller.
# app/controllers/surveys_controller.rb class SurveysController < ApplicationController before_action :set_survey, only: [:show, :edit, :update, :destroy, :answers] # ... ignoring content that hasn't changed from scaffold def answers @participants = Participant.all @questions = @survey.questions end private # ... ignoring content that hasn't changed from scaffold # Never trust parameters from the scary internet, only allow the white list through. def survey_params params.require(:survey).permit(:name, :questions_attributes => [:id, :content, :answers_attributes => [:id, :content, :participant_id] ]) end end
Because of Strong Parameters replacing
attr_accessible in Rails 4 we tell the application which attributes to allow through to our model to avoid mass-assignment security issues. The way it works is similar but you need to specify everything this includes the attributes within our nested models. (Don’t forget the
Next we setup a member route we can use to enter our answers and associate them with a survey.
# config/routes.rb SurveyApp::Application.routes.draw do resources :surveys do get 'answers', on: :member end resources :participants end
The behind the scenes work is done so lets sort out our views. Specifically the form so we can add the answers
# app/views/survey/answers.html.erb <h1><%= @survey.name %> Answers</h1> <%= form_for(@survey) do |f| %> <% @participants.each do |participant| -%> <h3><%= participant.name %></h3> <table> <thead> <tr> <td>Questions</td> <td>Answer</td> </tr> </thead> <tbody> <% @questions.each do |question| -%> <tr> <td><%= question.content %></td> <td> <%= f.fields_for :questions, question do |q| -%> <%= q.fields_for :answers, question.answers.find_or_initialize_by(participant: participant) do |a| -%> <%= a.text_area :content %> <%= a.hidden_field :participant_id, participant.id %> <% end -%> <% end -%> </td> </tr> <% end -%> </tbody> </table> <% end -%> <div class="actions"> <%= f.submit %> </div> <% end -%>
What we have done there is create a table for the Survey model in the usual, then nested within that
fields_for Questions and within that
fields_for Answers. This allows Rails to make use of the
accepts_nested_attributes_for method we used in the models.
For the Answers
fields_for we are using the
find_or_initialize_by method so that our answer
text_area will populate with data if it’s available and if there isn’t a record for that Participant and Question combination it initializes a model so the form builder has an object to map on to.
You’ll also notice a
hidden_field where we set the
participant_id for the record to ensure the answer gets associated to a participant (
fields_for will automatically create a
question_id as we use that model to build the answers object, view source on the page and you will see).
The way I have chosen to display this is perhaps not the most efficient but it demonstrates how you might tackle this scenario where you need to display all these options and still handle the data submission. If you have another solution to this please let me know on Twitter @createdbypete or create an issue on GitHub it would be interesting to compare.