167: More on Virtual Attributes
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.
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,
script/generate model tag name:string
There will have to be an association between the
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,
tag_id, that will be foreign keys on the
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.
Next we’ll quickly define the relationships in the models themselves.
class Tagging < ActiveRecord::Base belongs_to :article belongs_to :tag end
Tag needs to have a
has_many :though relationship with
class Tag < ActiveRecord::Base has_many :taggings, :dependent => :destroy has_many :articles, :through => :taggings end
Article will have a similar relationship with
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
Article. This will ensure that any
Taggings that are no longer needed will be removed when a
Article is destroyed.
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.
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
Having made the changes to our model class we can now test our code by adding a new article and giving it some 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.
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
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.
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.