To anyone who finds this tutorial useful I’d love some feedback or example implementations. Just leave a note at the bottom
So I’ve had the books in my possession for almost a year now, and I’ve been picking at it when I’ve had time and am part way through a “small” project called brewr. As I needed something with a little less degree of difficulty I’ve decided to develop the backend of jc-photo.net by hand myself and am using the new kid on the block, coding wise, Ruby on Rails. There’s plenty of history already written on the group behind it (37Signals). Anyhow, on with the show.
Many to Many Relationships with Details
No, we’re not talking about polygamy here, but rather the issues surrounding with linking models together.
Obviously with almost every database model you will be looking to link various bits of disparate data in different tables together. With RoR you have a number of options. There’s the has_and_belongs_to_many option, however as a join table this is limited as it gives no option for additional details about that join. Enter has_many :through (and it even has a blog dedicated to it!). Why is this important? Well, imagine you want to replicate flickr’s image functionality with tagging, sets and collections.
The Model
Obviously there are a set of relationships. In RoR this could be represented, taking into account a desire to order your sets in collections and images in your sets, by:
class Image < ActiveRecord::Base has_many :taggings,:dependent => true has_many :tags, :through => :taggings, :uniq => true has_many :imagesets,:dependent => true has_many :sets, :through => :imagesets, :uniq => true end
class Set < ActiveRecord::Base has_many :imagesets,:dependent => true has_many :images, :through => :imagesets, :uniq => true has_many :setcollections, :dependent => true has_many :collections, :through => :setcollections, :uniq => true end
class Collection < ActiveRecord::Base has_many :setcollections, :dependent => true has_many :collections, :through => :setcollections, :uniq => true end
class Tagging < ActiveRecord::Base belongs_to :image belongs_to :tag end
class Imageset < ActiveRecord::Base belongs_to :image belongs_to :set acts_as_list :scope => :sets end
class Setcollection < ActiveRecord::Base belongs_to :set belongs_to :collection end
If we look at the case of images in a set by itself, for ease of this write up, we need to add a little handler in there (you’ll see why in a bit). This is due to the desire to add an ordering component to our models. This would then alter, slightly, the models to this:
class Image < ActiveRecord::Base has_many :taggings,:dependent => true has_many :tags, :through => :taggings, :uniq => true has_many :imagesets,:dependent => true has_many :sets, :through => :imagesets, :uniq => true end
class Set < ActiveRecord::Base
has_many :imagesets,:dependent => true
has_many :images, :through => :imagesets, :uniq => true
has_many :setcollections, :dependent => true
has_many :collections, :through => :setcollections, :uniq => true
def image_ids=(image_ids)
image_ids.map!(&:to_i)
setcollections.each do |setcollection|
setcollection.destroy unless image_ids.include?setcollection.gallery_id
end
image_ids.each do |image_id|
self.setcollections.create(:image_id => image_id, :position => self.setcollections.length) unless setcollections.any?{ |sc| sc.image_id == image_id }
end
end
end
class Imageset < ActiveRecord::Base belongs_to :image belongs_to :set acts_as_list :scope => :sets end
The Controller
So now we have our relationships set up, we need to be able to, say, tag an image with tags. Let us assume that it is done on the editing phase for ease of an example.
def edit_set @set = Set.find(params[:id]) @images = Image.find(:all, :order => “name”) end
We grab all the images (though there may be better ways of handling this part in the long term) in addition to the actual set that we are looking at.
def update_set
params[:set][:image_ids] ||= []
@set = Set.find(params[:id])
if @set.update_attributes(params[:set])
@set.save
flash[:notice] = “Set was susccessfully updated.”
redirect_to :action => ‘view_set’, :id => @set
else
render :action => ‘edit_set’, :id => @set
end
end
The reason why we defined the function image_ids in the Set model is so that we can use the params[:set][:image_ids] to add or delete image references in the join table. When new ones are added, they are added at the end of the acts_as_list list. The catch here is that you need to force the items in the image_ids array to be integers, not strings (as is passed by the html form). This turned out to be a major debug point for me due to a background in PHP, with it’s rather lax type strictness.
The View
Now we need only cycle through the images for the check boxes, pre-checking where an existing join occurs (using the third boolean argument of check_box_tag @set.images.include?(image)). The update phase also takes care of the ordered nature of our set, with new images added after existing. You can then use the methodology well described to move the image reference up or down for each gallery.
<ul>
< % for image in @images %>
<li>
< %= check_box_tag "set[image_ids][]“, image.id, @set.images.include?(image) %>
</li>
< % end %>
</ul>
Conclusion
It took me some time to get to this point, with a major stumbling block being Ruby’s more strict method of ensuring variable types are treated differently - namely a string of 1 is not equivelant to an integer 1.
Update
Andrew was asking about the schema for the database tables, so I’ll add in the migrate details.
@Andrew: I will be writing up anything I find useful that I can’t find assistance with on the web.
In terms of a migration for the tables, it would look something like this (I’ll update in the post too):
class CreateImages < ActiveRecord::Migration
def self.up
create_table :images do |t|
t.column :name, :string, :null => false
t.column :permalink, :string, :null => false
t.column :description, :text, :null => false
t.column :filename, :text, :null => false
end
end
def self.down
drop_table :images
end
end
class CreateSets < ActiveRecord::Migration
def self.up
create_table :sets do |t|
t.column :name, :string, :null => false
t.column :permalink, :string, :null => false
t.column :description, :text, :default => nil
end
end
def self.down
drop_table :sets
end
end
and the complex join table
class CreateImagesets < ActiveRecord::Migration
def self.up
create_table :imagesets do |t|
t.column :position, :int, :null => false
t.column :gallery_id, :int, :null => false
t.column :image_id, :int, :null => false
end
execute "alter table imagesets add foreign key (set_id) references sets(id)"
execute "alter table imagesets add foreign key (image_id) references images(id)"
end
def self.down
drop_table :imagesets
end
end
…due to a background in PHP, with it’s rather lax type strictness….
Typo? I suspect you meant ‘almost non-existent’
Great tutorial… there should be more stuff like this out there.
One question: How would you set up the tables for this?
Thanks.
@Andrew: I will be writing up anything I find useful that I can’t find assistance with on the web.
In terms of a migration for the tables, it would look something like this (I’ll update in the post too):
class CreateImages < ActiveRecord::Migration def self.up create_table :images do |t| t.column :name, :string, :null => false t.column :permalink, :string, :null => false t.column :description, :text, :null => false t.column :filename, :text, :null => false end end def self.down drop_table :images end endclass CreateSets < ActiveRecord::Migration def self.up create_table :sets do |t| t.column :name, :string, :null => false t.column :permalink, :string, :null => false t.column :description, :text, :default => nil end end def self.down drop_table :sets end endand the complex join table
class CreateImagesets < ActiveRecord::Migration def self.up create_table :imagesets do |t| t.column :position, :int, :null => false t.column :gallery_id, :int, :null => false t.column :image_id, :int, :null => false end execute “alter table imagesets add foreign key (set_id) references sets(id)” execute “alter table imagesets add foreign key (image_id) references images(id)” end def self.down drop_table :imagesets end endok i decided to read this post as i thought it would be some heart felt spill on life and woman. PLEASE NEVER do that to me again!!! you are banned from providing misleading titles in you posts!!!
You obviously failed to notice where it was filed (Ruby on Rails, design programming) or it’s tags (
has many, many to many relationship, programming, Rails, Ruby, ruby on rails, through, tutorial) and those combined with the title referring to “Learning Rails” makes it pretty obvious that it was not about spilling my heart on life, love and women.
For the title “Learning Rails: Many-to-Many Relationships”, I don’t think you could have come up with a more confusing example. If you intend for beginners to “Learn” about “Many-to-Many Relationships”, a stripped down example (with 3 models maximum) would be more effective.
Thank you, none the less, for the code, once I decipher it.
@confused: while I understand your comment regarding the complexity of the tutorial, it was because I couldn’t find any tutorials that covered ordered-many-to-many relationships that I wrote this one. True, it does involve two relationships (collection-to-set and set-to-image) and thus has five tables, it was based on a real world (and highly useful) “gallery” system - flickr. So, I apologise for the complexity but countenance that this was about my learning process and the tips and tricks associated with how to do things when I couldn’t find relevant tutorials already in existence.
Joel: I disagree with confused and reallyu appreciate the example. Yes, a couple of tables than the basics but it was based on a real world example of Flickr which made this tutorial different, and in my opinion, better. There are a handful of the way-too simplified versions out there for people who want the very basics. Your example had the added advantage of showing a real-world practical dimension that the others don’t due to the simplicity. Thanks!
Great example for a non-trivial m:m relationship. Wasn’t /exactly/ what I needed, but pointed me in the right direction to resolve a problem that had me baffled for a day…