News

Mike's Rails Zone: Handling Many-to-Many Relationships in Rails

Rails makes it very easy to set up many-to-many relationships between entities in your application. In this column, I'll show you one approach to handling such a relationship in the user interface as well. I'm going to assume you know the basics of Rails and its MVC approach to the world but, even if you don't, this should give you some idea of the overall flavor of working with Rails.

For this little application, called school, I'll take the relationship between students and courses as my domain: A student can enroll in more than one course, and a course has more than one student (or so its professor hopes). The plan is to allow managing students and courses separately, but also to provide an easy interface for adding students to courses.

To start at the lowest level, let's look at the database setup. Here are the Rails migrations for the tables I'll be using:


class CreateStudents < ActiveRecord::Migration
  def self.up
    create_table :students do |t|
      t.string :name
      t.string :student_id

      t.timestamps
    end
  end

  def self.down
    drop_table :students
  end
end

class CreateCourses < ActiveRecord::Migration
  def self.up
    create_table :courses do |t|
      t.string :course_number
      t.string :name

      t.timestamps
    end

    create_table :courses_students, :id => false do |t|
      t.integer :course_id
      t.integer :student_id
    end
  end

  def self.down
    drop_table :courses_students
    drop_table :courses
  end
end

Of course, I didn't write the bulk of those migrations by hand; they get generated as part of creating the model. The creation of the joining table (courses_students) still has to be added in by hand, though.

Let's move up a level to the actual models involved (below):

 

 

 

 

 

 

 

 

 


class Course < ActiveRecord::Base
  has_and_belongs_to_many :students
end

class Student < ActiveRecord::Base
  has_and_belongs_to_many :courses
  
  def candidate_courses
    Course.find(:all, :order => "name ASC")
  end
  
  def enrolled?(course)
    courses.include?(course)
  end
end

Note the two methods on the Student model: it can tell us whether a student is enrolled in a particular course, and provide a list of all the sources that a student is eligible to enroll in. For this little demonstration, the latter simply returns all courses, but there's no reason it couldn't do more complex processing.

To send all the HTTP requests to the right place, the code uses three entries in routing.rb


map.root :controller => "students"
map.resources :students, :member => { :show_courses => :get, :save => :post }
map.resources :courses

Courses and students are both resources for this application, which gives us the standard RESTful routing in Rails, with actions like index, new, edit and so on doing the right thing. But you'll see that a Student resource also gets two extra members: show_courses and save. These handle the details of wiring up students and courses on the user interface.

Let's look at how an individual student's courses can be entered. The process starts at the index view for students, which displays the entire student list:


<h1>Students</h1>

<%= link_to 'New student',  new_student_path %>

<table>
  <tr>
    <th>Student ID</th>
	<th>Name</th>
  </tr>

<% for student in @students %>
  <tr>
    <td><%=h student.student_id %></td>
    <td><%=h student.name %></td>
    <td><%= link_to 'Show', student %></td>
    <td><%= link_to 'Edit', edit_student_path(student) %></td>
    <td><%= link_to 'Destroy', student, :confirm => 'Are you sure?', 
         :method => :delete %></td>
    <td><%= link_to 'Courses', show_courses_student_path(student) %></td>
  </tr>
<% end %>
</table>

<%= link_to "Courses", courses_path %>

Most of that is pretty standard RESTful launch code. But what's new is the link for each student to show their courses, which is pointed at show_courses_student_path - Rails knows where this should go thanks to our routing entries. In the controller for Students, this method simply retrieves the requested student and hands it off to the view:


def show_courses
  @student = Student.find(params[:id])
end

In the view, all of the courses are displayed with checkboxes, as you can see in Figure 1, below.

School Figure 1

 

 

 

But how does a student enroll in a new course (or unenroll from an existing one?). Why, that's handled by a simple AJAX call in the show_courses view:


<h1>Courses for <%= @student.name %></h1>

<table>
  <tr>
    <th>Enroll</th>
    <th>Course</th>
  </tr>

<% @student.candidate_courses.each do |c| %>
	<tr>
	    <td><%=check_box_tag c.id, 1, @student.enrolled?(c), 
		  :onclick => remote_function(
		  :url => save_student_path(@student), 
		  :with => "'course=#{c.id}&amp;show=' + this.checked", 
		  :method => :post) %>
		</td>	
		<td><%=h c.name %></ts>
	</tr>
<% end %>
</table>

<%= link_to 'Back', student_path(@student) %>

The remote_function call does the heavy lifting of communicating back from our view to the corresponding controller, by posting a request back to the save action in the controller - we let the standard browser behavior take care of actually displaying the checkmarks. Here's the code from the controller:


def save
  @student = Student.find(params[:id])
  @course = Course.find(params[:course])
  if params[:show] == "true"
    @student.courses << @course
  else
    @student.courses.delete(@course)
  end
  @student.save!
  render :nothing => true
end

Nothing too earth-shattering there: if the checkbox has just been checked, add the course to the course collection for the student; otherwise, delete the course. Note the final rendering of nothing -- this prevents Rails from sending anything back to the user interface.

The overall results: We get a simple user interface for managing a many-to-many relationship, and one that doesn't require the end user to wait for the page to render every time they check a checkbox. If you'd like to play with the full sample code (tested with Rails 2.10) you can download it by clicking here.

About the Author

Mike Gunderloy has been developing software for a quarter-century now, and writing about it for nearly as long. He walked away from a .NET development career in 2006 and has been a happy Rails user ever since. Mike blogs at A Fresh Cup.