Code

Learning Rails: Many-to-Many Relationships

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, :o rder => "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

10 Comments

speak up

Add your comment below, or trackback from your own site.

Subscribe to these comments.

Be nice. Keep it clean. Stay on topic. No spam.

You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

*Required Fields