advertisement

Print

Understanding ActiveRecord: A Gentle Introduction to the Heart of Rails (Part 2)
Pages: 1, 2, 3, 4

Tying It All Together with Tags

W're already two-thirds of the way to our half-app, without too much work. However, the trickiest part of this tutorial will certainly be hooking up the Tag model in a way that really gets us where we need to be.



If we just wanted to have each entry have its own tags, that'd be pretty straightforward, mostly just repeating what we just did with User and Entry. The trouble is that we really want to also be able to go in the other direction as well, looking up entries by tag name.

This diagram illustrates the other half of the relationship that was left out of the first diagram:

figure

This is a many-to-many relationships, where each tag may potentially reference many entries, and each entry may have many tags. Rails has a facility for this called has_and_belongs_to_many, but for a number of reasons, the cool kids often use has_many :through. It tends to be the right choice whenever you might need to change things down the line, so that is the method we'll use.

Let's start with the Tag model. All we really need is a name attribute, and we probably want it to be unique.

You're probably familiar with how to script up the boilerplate by now, so I'll just list my migration and model definitions here, and leave it to you to do the rest:

db/migrate/004_create_tags.rb
class CreateTags < ActiveRecord::Migration
  def self.up
    create_table :tags do |t|
      t.column :name, :string
    end
  end

  def self.down
    drop_table :tags
  end
end
app/models/tag.rb
class Tag < ActiveRecord::Base
  validates_uniqueness_of :name
  validates_presence_of :name
end

Building Our Join Table

I'm going to steal the table name from the acts_as_taggable Rails plugin for this concept: Tagging. The Tagging model will be what we route our has_many :through calls, and will form the final model in our app.

It's just a regular model, so you generate it the same way as all the others. The table definition looks like this:

db/migrate/005_create_taggings.rb
class CreateTaggings < ActiveRecord::Migration
  def self.up
    create_table :taggings do |t|
      t.column :entry_id, :integer
      t.column :tag_id, :integer
    end
  end

  def self.down
    drop_table :taggings
  end
end

If you haven't run rake db:migrate in a while, now is the time to do that. We'll now add to our models the definitions necessary to set up our many to many relationship. We need to tell Rails a little more about the relationship this time around, and this is simply due to the fact that we don't need to modify the Entry or Tag table definition to set up the relationship.

At any rate, the general idea is that an Entry has many tags, and independently, a Tag is associated with many entries, so each model has many "taggings" associated with it. A single Tagging defines the link between an Entry and a Tag.

Here is how we translate that idea to Rails, in each of our models:

app/models/entry.rb
class Entry < ActiveRecord::Base
  # rest of definitions omitted
  has_many :taggings
  has_many :tags, :through => :taggings
app/models/tag.rb
class Tag < ActiveRecord::Base
  #rest of definitions omitted
  has_many :taggings
  has_many :entries, :through => :taggings
end
app/models/taggings.rb
class Tagging < ActiveRecord::Base
  belongs_to :tag
  belongs_to :entry
end

Before we go further, we should add some methods to our Entry and Tag that will simplify working with tags.

class Entry < ActiveRecord::Base

  # rest of definitions omitted

  # Adds a tag with the given name, if it's not already present
  def tag_as(tagname)
    unless tagged_as?(tagname)
      tags << Tag.find_or_create_by_name(tagname)
    end 
  end

  # True if tags include a Tag with the given name, False otherwise
  def tagged_as?(tagname)
    tag_names.include?(tagname)
  end

  # returns a list of tag names
  def tag_names
    tags.map(&:name)
  end

end

This brings us very close to where we need to be: We can actually model all the data from the diagram at the beginning of this article. Let's start from a blank slate by wiping all our data. Run the following commands, and then fire up the console:

rake db:migrate VERSION=0
rake db:migrate

This explains what all those self.down methods are for in your migrations: Rails lets you rewind your schema changes as needed, which is exactly what we just did.

Slowly Reaching the Fun Part

Here is my console session, which created something similar to what the first diagram showed:

>> joe = User.create(:username => "joe", :email => "joe@test.com", 
                     :display_name => "Joe User")
>> al = User.create(:username => "al", 
                    :email => "al@test.com", :display_name => "Al Hacker")

>> joe.entries.create(:url => "redhanded.hobix.com", :short_description => "_why's blog")
>> joe.entries.create(:url => "rubyreports.org",:short_description => "Ruby Reporting lib")
>> rh = joe.entries.find_by_url("redhanded.hobix.com")           
>> rh.tag_as "chunky_bacon" 
>> rh.tag_as "_why"        

>> rp = joe.entries.find_by_url("rubyreports.org")
>> rp.tag_as("awesome")
>> rp.tag_as("reporting")

>> al.entries.create(:url => "google.com", :short_description => "Search Engine")
>> al.entries.create(:url => "notrubyreports.org", 
?>                   :short_description => "Lame Reporting")

>> gg = al.entries.find_by_url("google.com")
>> gg.tag_as("search")
>> gg.tag_as("borg")  

>> nrr = al.entries.find_by_url("notrubyreports.org") 
>> nrr.tag_as("boring")
>> nrr.tag_as("reporting")

And here is some evidence that it's all working:

>> Tag.find_by_name("reporting").entries.map { |e| e.url }
=> ["rubyreports.org", "notrubyreports.org"]
>> Tag.find_by_name("reporting").entries.map { |e| e.user.display_name }
=> ["Joe User", "Al Hacker"]
>> al.entries.find_by_url("google.com").tag_names 
=> ["search", "borg"]

Making Things Prettier

Tag.find_by_name(tagname).entries looks like something we'd probably be typing a lot, and Tag.entries_by_name might be a little easier on the eyes. We can do this by creating a class method on Tag, which looks like this:

class Tag < ActiveRecord::Base

  # rest of stuff omitted

  def self.entries_by_name(tagname)
    my_tag = find_by_name(tagname)
    my_tag.entries.uniq if my_tag
  end

end

The end result is something like this:

>> Tag.entries_by_name("reporting").map { |r| r.url } 
=> ["rubyreports.org", "notrubyreports.org"]
Pages: 1, 2, 3, 4

Next Pagearrow