The other day, I wanted to implement something with Ruby on Rails that – as far as I could tell – was not very well documented. As I’m a beginner with Ruby and with Rails, I’m not sure in how far this is the best way to go, but it’s a working way.
What I want to do is have three tables: movies, dancers and their join table that I called dancer_movies. The main goal of this exercise is to be able to add dancers directly via a drop down that lists all dancers from the database (and the number of dances they appeared in in that movie) when creating a new movie entry.First of all I’ll create a new rails directory called movies:
$shell: rails movies
Then I’ll prepare the database by creating the tables I need.
CREATE DATABASE `movies` ;
CREATE TABLE `dancers` (
`id` INT( 11 ) NOT NULL AUTO_INCREMENT ,
`firstname` VARCHAR( 100 ) NOT NULL ,
`lastname` VARCHAR( 100 ) NOT NULL ,
PRIMARY KEY ( `id` )
);
CREATE TABLE `movies` (
`id` INT( 11 ) NOT NULL AUTO_INCREMENT ,
`title` VARCHAR( 200 ) NOT NULL ,
PRIMARY KEY ( `id` )
);
CREATE TABLE `dancer_movies` (
`id` INT( 11 ) NOT NULL AUTO_INCREMENT ,
`dancer_id` INT( 11 ) NOT NULL ,
`movie_id` INT( 11 ) NOT NULL ,
`numofdances` INT( 11 ) NOT NULL ,
PRIMARY KEY ( `id` )
);
I will have to adapt my database.yml in the section “development”, the name of the development database is just plain “movies”.
I’ll have rails do most of the work by letting it create a scaffold for movies and my other two models:
ruby script\generate scaffold movie
ruby script\generate model dancer
ruby script\generate model dancer_movie
Now I have to let Rails know about the associations:
app/models/movie.rb:
class Movie < ActiveRecord::Base
has_many :dancer_movies, :dependent => true
has_many :dancers, :through => :dancer_movies
end
app/models/dancer.rb:
class Dancer < ActiveRecord::Base
has_many :dancer_movies, :dependent => true
has_many :movies, :through => :dancer_movies
end
app/models/dancer_movies.rb:
class DancerMovie < ActiveRecord::Base
belongs_to :dancer
belongs_to :movie
end
The view needs to be adapted, so it will list dancers and their number of dances per movie.
app/views/movies/list.rhtml:
<h1>Listing movies</h1>
<table border=1>
<tr>
<% Movie.content_columns.each do |column| %>
<th><%= column.human_name %></th>
<% end %>
<!-- XXX -->
<th>Dancers</th>
<th colspan=3>Action</th>
<!-- XXX -->
</tr>
<% @movies.each do |movie| %>
<tr>
<% Movie.content_columns.each do |column| %>
<td><%=h movie.send(column.name) %></td>
<% end %>
<!-- XXX -->
<td>
<table border=0 cellspacing=0 cellpadding=0>
<% movie.dancer_movies.each do |appearance| %>
<tr><td><%= appearance["numofdances"] %> <%=
appearance.dancer.firstname %> <%= appearance.dancer.lastname %></td></tr>
<% end %>
</table>
</td>
<!-- XXX -->
<td><%= link_to 'Show', :action => 'show', :id => movie %></td>
<td><%= link_to 'Edit', :action => 'edit', :id => movie %></td>
<td><%= link_to 'Destroy', { :action => 'destroy', :id => movie }, :confirm =>
'Are you sure?', :post => true %></td>
</tr>
<% end %>
</table>
<%= link_to 'Previous page', { :page => @movie_pages.current.previous } if
@movie_pages.current.previous %>
<%= link_to 'Next page', { :page => @movie_pages.current.next } if
@movie_pages.current.next %>
<br />
<%= link_to 'New movie', :action => 'new' %>
I also need to extend the _form.rhtml a bit, so when editing or creating a movie I get a number of drop down boxes with the dancers to chose from, and a text field to provide the number of dances they’re in.
app/views/movies/_form.rhtml:
<!--[form:movie]-->
<p><label for="movie_title">Title</label><br/>
<%= text_field 'movie', 'title' %></p>
<!-- XXX -->
<table border=1>
<tr>
<td><label for="dancer_movies_numofdances">No. of Dances</label></td>
<td><label for="dancer_movies_dancer_id">Dancer</label></td>
</tr>
<% @dancer_movies.each do |@appearance| %>
<tr>
<td><%= text_field_tag ("appearance[numofdances][]",
@appearance.numofdances, "size" => "5") %></td>
<td><select id="appearance_dancer_id" name="appearance[dancer_id][]">
<option value=""> </option>
<%= options_from_collection_for_select (Dancer.find_all,
"id", "lastname", selected_value = @appearance.dancer_id) %>
</select></td>
</tr>
<% end %>
</table>
<!-- XXX -->
<!--[eoform:movie]-->
I’ll need to define @dancer_movies in the “new”-function in the controller: The part with the “15.times do” is merely there so there are 15 empty dancer_movies when creating a new one – that way I get 15 text_fields and drop downs.
app/controller/movies_controller.rb:
def new
@movie = Movie.new
@dancer_movies = Array.new
15.times do
@dancer_movies << DancerMovie.new
end
end
When saving a movie, I also want to save its dancers and their number of dances, so I change the “create”-function a bit. First I’ll delete all the entries from the parameters “appearance” that are empty strings (no choice or entry was made there, so there’s nothing to save either). Then I’ll iterate over the number of dancers that were added and create new DancerMovie-objects that I then iterate over to create every appearance (save it in the database).
def create
@movie = Movie.new(params[:movie])
if @movie.save
# NEW ##
@dancer_movies = Array.new
params[:appearance]['numofdances'].delete("")
params[:appearance]['dancer_id'].delete("")
for i in 0...params[:appearance]['dancer_id'].length
@dancer = Dancer.find(params[:appearance]['dancer_id'][i])
@dancer_movies << DancerMovie.new(:movie => @movie,
:dancer => @dancer,
:numofdances =>
params[:appearance]['numofdances'][i])
end
@movie.dancers(true)
for appearance in @dancer_movies
appearance.create
end
# END of new part ##
flash[:notice] = 'Movie was successfully created.'
redirect_to :action => 'list'
else
render :action => 'new'
end
end
Same thing as in the “new”-function: When editing a movie, the user might very well want to add a dancer, so I append a few extra dancer_movies to the array of existing ones.
def edit
@movie = Movie.find(params[:id])
@dancer_movies = @movie.dancer_movies
10.times do
@dancer_movies << DancerMovie.new
end
end
Same thing as in the “create”-function: When a movie gets updated, I want to update its dancers and their number of appearances as well. What I do here is first find all DancerMovies that belong to the particular movie and delete them and afterwards save every remaining appearance in the database.
def update
@movie = Movie.find(params[:id])
if @movie.update_attributes(params[:movie])
@dancer_movies = DancerMovie.find(:all, :conditions => ["movie_id = ?",
@movie.id])
for old_appearance in @dancer_movies
DancerMovie.delete(old_appearance.id)
end
@new_appearances = Array.new
params[:appearance]['numofdances'].delete("")
params[:appearance]['dancer_id'].delete("")
for i in 0...params[:appearance]['dancer_id'].length
@dancer = Dancer.find(params[:appearance]['dancer_id'][i])
@new_appearances << DancerMovie.new(:movie => @movie,
:dancer => @dancer,
:numofdances =>
params[:appearance]['numofdances'][i])
end
@movie.dancers(true)
for appearance in @new_appearances
appearance.create
end
flash[:notice] = 'Movie was successfully updated.'
redirect_to :action => 'show', :id => @movie
else
render :action => 'edit'
end
end
Many-to-many relationships with attributes are quite difficult to handle for beginners – especially with a lack of documentation.
I’m looking forward to getting feedback and to find out whether there is a more direct / more object oriented way to implement this (especially the create and update functions).
Position Question
Your example works great for me, except every time I update an item, all the existing appearances get new position numbers and are sent to the back because they are treated as new entries. Is there a better way to update the appearances without removing then reentering?
-Lance
Back Button Error
Thank you for the great introduction. I noticed that if you enter edit mode for movies, and lets say change your mind and do not enter any data, when you click the back button you get a nil object error. The only way back is via the edit button.
-Chris
So where’s the list action controller? There are back / fwd buttons in the rhtml, but @movie_pages is not hooked up to anything, and wouldn’t be given this implementation. That would be the really interesting part of this!
-James
I’m running into tons of errors implementing this, eg:
“The :dependent option expects either :destroy, :delete_all, or :nullify (true)”
I have a feeling this is because I’m using Rails 2. It would be nice if it could be specified what version(s) of Rails examples are compatible with.
-JackWe should have opted for migrations than pure sql, for creating the DB structure. This would be a good signal for the beginners.
-s