News
Mike's Rails Zone: Handling Many-to-Many Relationships in Rails
- By Mike Gunderloy
- September 29, 2008
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.
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}&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.