167: More on Virtual Attributes
(view original Railscast)
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, 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.
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 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.
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.