homeASCIIcasts

167: More on Virtual Attributes 

(view original Railscast)

Other translations: Cn

Virtual attributes were first covered back in Episode 16. If you’re unfamiliar with them then you should read or watch that episode first as it will help you understand this one. Virtual attributes are a great help if you have to deal with complex forms, in which the fields don’t map cleanly onto your model’s attributes.

In this episode we’ll combine virtual attributes with a callback to add tagging to a blogging application. We’ll start with a basic app that has a number of articles.

The articles page from our example blogging app.

What we’d like to do is add the ability to tag to a new article, adding either new or existing tags by specifying them in a text field. There are a number of tagging plugins available for Rails that we could use, but we’re going to create our solution from scratch to show how easy it is to do this by making use of virtual attributes.

Creating The Models

We’ll start by creating a Tag model. This is a simple model that has just one attribute, name.

script/generate model tag name:string

There will have to be an association between the Tag and Article models. As an article can have many tags and a tag can belong to many articles it will have to be a many-to-many relationship. We’ll create a join model for this which we’ll called Tagging. This model will just have two integer fields, article_id and tag_id, that will be foreign keys on the Article and Tag models.

script/generate model tagging article_id:integer tag_id:integer

To complete the creation of our two new models we just need to run the newly created migrations.

rake db:migrate

Next we’ll quickly define the relationships in the models themselves. Tagging will belong_to both Article and Tag.

class Tagging < ActiveRecord::Base
  belongs_to :article
  belongs_to :tag
end

Tag needs to have a has_many :though relationship with Article.

class Tag < ActiveRecord::Base
  has_many :taggings, :dependent => :destroy
  has_many :articles, :through => :taggings
end

Likewise Article will have a similar relationship with Tag.

class Article < ActiveRecord::Base
  has_many :comments, :dependent => :destroy
  has_many :taggings, :dependent => :destroy
  has_many :tags, :through => :taggings
  validates_presence_of :name, :content
end

Note the use of :dependent => :destroy in both Tag and Article. This will ensure that any Taggings that are no longer needed will be removed when a Tag or Article is destroyed.

The View

With the models in place we can now alter the new article view to add a text field where we can add a space-separated list of the tags that we want to associate with the article. Because we’re converting the text from the field into an association the cleanest way to do this will be to use a virtual attribute.

<% form_for @article do |form| %>
  <ol class="formList">
    <li>
      <%= form.label :name, "Name" %>
      <%= form.text_field :name %>
    </li>
    <li>
      <%= form.label :tag_names, "Tags" %>
      <%= form.text_field :tag_names %>
    <li>
      <%= form.label :content, "Content" %>
      <%= form.text_area :content, :rows => 10 %>
    </li>
    <li>
      <%= form.submit "Submit" %>
    </li>
  </ol>
<% end %>

The new article form with the tag_names field added.

Our Article model doesn’t have a tag_names attribute so we’ll create a virtual attribute to represent the string of tags that is assigned to an article. Previously we’ve used getter and setter methods to create virtual attributes. For our Article model we could create a tag_names= method and use that to set the article’s tags. There are disadvantages to this approach though, one of which is that creating the tags in the setter method will create Tagging records every time the attribute has its value set, whether the article itself is saved or not.

A better way to do this is by using a callback. An after_save callback in Article will ensure that only when an article is saved are its tags saved. The model will now look like this:

class Article < ActiveRecord::Base
  has_many :comments, :dependent => :destroy
  has_many :taggings, :dependent => :destroy
  has_many :tags, :through => :taggings
  validates_presence_of :name, :content

  attr_accessor :tag_names
  after_save :assign_tags

  private
  def assign_tags
    if @tag_names
      self.tags = @tag_names.split(/\s+/).map do |name|
        Tag.find_or_create_by_name(name)
      end
    end
  end

end

Note that we still need getter and setter methods for the virtual tag_names attribute, but that this is now done with an accessor. The private assign_tags method that is called after the article is saved first checks that @tag_names is not nil and if it isn’t splits its value at any spaces it finds to create an array. It then uses map to iterate over the array and returns a Tag for each value. It does this by using the find_or_create_by_name method to return a Tag with a given name, creating it first if it doesn’t exist. Finally we assign the array of Tags to the Article’s tags attribute.

Having made the changes to our model class we can now test our code by adding a new article and giving it some tags.

Adding a new article with tags.

Once we’ve added the article we can use the Rails console to see if its tags have been added correctly.

>> a = Article.last
=> #<Article id: 3, name: "New Article", content: "I am a new article.", author_name: nil, created_at: "2009-06-24 20:38:56", updated_at: "2009-06-24 20:38:56">
>> a.tags
=> [#<Tag id: 1, name: "stuff", created_at: "2009-06-24 20:38:56", updated_at: "2009-06-24 20:38:56">, #<Tag id: 2, name: "things", created_at: "2009-06-24 20:38:56", updated_at: "2009-06-24 20:38:56">]

They have! Two new tags have been created and have been associated with our article. We’re not quite finished yet though. If we try to edit our article its Tags field will be blank.

The tags field is not repopulated when the article is invalid.

To fix this we need to update the Article model and write a getter method for tag_names that will return an article’s tags’ names as a string. As we’re adding an explicit getter method we’ll also have to replace our attr_accessor with an attr_writer.

def tag_names
  @tag_names || tags.map(&:name).join(' ')
end

The method above will return the @tag_names instance variable if it has already been assigned; otherwise it will return a string of all of the article’s tags’ names separated by a space.

Working With Validators

Another advantage of this approach is that it works well with validators. If we add another tag but make the form invalid by removing the content, an error message will be shown and the content of the Tags field will be maintained in the form.

The tags field now has its value maintained when the form in invalid.

As we’ve used an after_save callback the new tag “etc” won’t be created as the article wasn’t saved. The tag and association will only be created when the form is valid and the article is saved.

We now have the ability to tag our articles simply. The combination of virtual attributes and callbacks is well worth using in your applications when your forms become complex or have fields from associated models.