#16 Virtual Attributes (revised)
- Download:
- source codeProject Files in Zip (66.3 KB)
- mp4Full Size H.264 Video (25.9 MB)
- m4vSmaller H.264 Video (13.2 MB)
- webmFull Size VP8 Video (16.3 MB)
- ogvFull Size Theora Video (31.3 MB)
Virtual attributes are something that can be used in almost any Rails application. Sometimes we want to make changes to a user interface that don’t map directly to database fields and as an example we have a form below for editing a product.
This form currently has four fields and these map directly to fields in the database. If we look at the schema file we’ll see the columns that the products
table has.
create_table "products", :force => true do |t| t.string "name" t.integer "price_in_cents" t.datetime "released_at" t.integer "category_id" t.datetime "created_at", :null => false t.datetime "updated_at", :null => false end
The table has four columns (excluding the automatically-generated created_at
and updated_at
fields) and these are mapped directly to fields in our form. This approach doesn’t always lead to the best user experience, however. For example the price is stored in cents but it’s more convenient for the user to enter it in dollars. We can use virtual attributes to do this and we’ll show you how in this episode.
Adding a Virtual Attribute For a Price
The view template for the form renders the price_in_cents
field like this.
<div class="field"> <%= f.label :price_in_cents %><br /> <%= f.text_field :price_in_cents %> </div>
We’ll replace it with a virtual price_in_dollars
field.
<div class="field"> <%= f.label :price_in_dollars %><br /> <%= f.text_field :price_in_dollars %> </div>
In the Product
model we’ll need to add getter and setter methods to handle the conversion between dollars and cents.
class Product < ActiveRecord::Base attr_accessible :name, :price_in_dollars, :released_at, :category_id belongs_to :category has_many :taggings has_many :tags, through: :taggings def price_in_dollars price_in_cents.to_d / 100 if price_in_cents end def price_in_dollars=(dollars) self.price_in_cents = dollars.to_d * 100 if dollars.present? end end
As we’ve changed a form field we’ve also changed the fields in attr_accessible
to match. When we reload the page now the product has its price displayed in dollars.
We can now enter a different amount in dollars in this form ad the product’s price will be updated correctly.
A Better Date Field
With just a couple of methods tucked away in the model we’ve improved the user interface so next we’ll look at the Release Date & Time field.
<div class="field"> <%= f.label :released_at, "Release Date & Time" %><br /> <%= f.datetime_select :released_at %> </div>
Using select menus to set the time isn’t the most efficient way to enter a time value so we’ll replace this with a text field that we’ll call released_at_text
.
<div class="field"> <%= f.label :released_at_text, "Release Date & Time" %><br /> <%= f.text_field :released_at_text %> </div>
Again, we’ll need to add getter and setter methods in the model and update the fields in attr_accessible
.
attr_accessible :name, :price_in_dollars, :released_at_text, :category_id def released_at_text released_at.try(:strftime, "%Y-%m-%d %H:%M:%S") end def released_at_text=(time) self.released_at = Time.zone.parse(time) if time.present? end
We use strftime
to convert the time into a string here but you can handle this conversion however you like. There’s a chance that this attribute may be nil
so we use try
so that the attribute returns nil
if this is the case. The setter accepts the time value that was typed in as a string and we want to convert this to a datetime value so we use Time.parse
here. (It’s important to use Time.zone
so that Rails’ active timezone is taken into consideration.) Note that we only do this if a time value is present and that we’ve changed the attributes listed in attr_accessible
again to reflect our new field. When we reload the page now the release time is shown in a text field displayed in the format that we specified in the getter method.
We aren’t restricted to this format when we set the time. We could enter, say, “March 22 8 AM” and this will be parsed correctly and the time set. If we want to go further with the string to time conversion we can use the Chronic gem. This allows users to enter relative values such as “tomorrow” or “yesterday at 4:00”. We won’t use this here but it would be quite easy to add this functionality to our application.
One issue with our current datetime parser is that it doesn’t like out-of-range values. If we try to set an invalid period this will raise an “invalid date” exception. To fix this we can rescue
from the error in the setter and make the released_at
field blank.
def released_at_text=(time) self.released_at = Time.zone.parse(time) if time.present? rescue ArgumentError self.released_at = nil end
It’s a good idea to add a validation to ensure the released_at
attribute is set. This way the user has a chance to correct it if it is formatted incorrectly.
validates_presence_of :released_at
If we enter an invalid date value now we’ll see a validation error.
This isn’t the best use experience as the invalid date the user entered isn’t displayed so that it can be fixed. This is a fundamental issue with how we’re handling this virtual attribute. The time value that’s entered by the user is lost by the time it reaches the validations, but for the best user experience it should be available. To fix this we need to change the order in which things are done. Instead of having a setter method containing the parsing logic we’ll set an instance variable through an attr_writer
. We can then check this variable in the released_at_text
method to see if it’s set or fall back to the actual released_at
datetime. The setter logic can now happen through a callback after the validations take place so we use a before_save
callback in the model and call save_released_at_text
after the model is saved. Here we set the released_at value based on the instance variable. Rescuing from the ArgumentError
now fits better in the validation step instead of in the callback so instead of using validates_presence_of
we write a custom validator that checks that the @released_at_text
value is present and tries to parse it if it is. If not an appropriate validation message is shown.
class Product < ActiveRecord::Base attr_accessible :name, :price_in_dollars, :released_at_text, :category_id belongs_to :category has_many :taggings has_many :tags, through: :taggings attr_writer :released_at_text before_save :save_released_at_text validate :check_released_at_text # price_in_dollars fields omitted def released_at_text @released_at_text || released_at.try(:strftime, "%Y-%m-%d %H:%M:%S") end def save_released_at_text self.released_at = Time.zone.parse(@released_at_text) if @released_at_text.present? end def check_released_at_text if @released_at_text.present? && Time.zone.parse(@released_at_text).nil? errors.add :released_at_text, "cannot be parsed" end rescue ArgumentError errors.add :released_at_text, "is out of range" end end
Now when we try to set an invalid date we’ll see an appropriate error message.
Similarly if we enter a date that can’t be parsed we’ll see an error message telling us that. This solution isn’t nearly as clean as simple getter and setter methods but if we need to persist invalid data then this level of complexity may be necessary.
Associations
Next we’ll show you a couple of ways that virtual attributes can help with associations. A user can set the category that a product belongs to but we want to allow them to enter a new category in the same form. To do this we’ll add a text field to the form called new_category
.
<div class="field"> <%= f.label :category_id %><br /> <%= f.collection_select :category_id, Category.order(:name), :id, :name %> or create one: <%= f.text_field :new_category %> </div>
In the Product
model we need to add this field as a virtual attribute. We’ll do this with attr_accessor
which will automatically create getter and setter methods for us and store the value in an instance variable. Also we’ll need to add the new field in the attr_accessible
list so that it can be set through mass-assignment.
attr_accessible :name, :price_in_dollars, :released_at_text, :category_id, :new_category attr_accessor :new_category
Reloading the page now will show the new field in the page.
Next we need to create a callback so that when a product is saved it creates the category to go with it. We’ll write another before_save
callback called create_category
to do this. This will set the product’s category
to a new Category
based in the value in the text field if a value has been entered there.
before_save :create_category def create_category self.category = Category.create(name: new_category) if new_category.present? end
We’re assuming here that the category is valid but if there’s a possibility that it isn’t we should add a custom validator like we did with the release date field. This way an error will be triggered before the new category is created. We’ll put one of the products into a new “Board Games” category now.
This works but what if we have a many-to-many relationship that we want to set through the form? We already have such a relationship set up in the Product
model: a Product
has many Tags
through a join model called Taggings
. The product page shown above lists the tags that a product belongs to but we currently have no way to edit the tags through the form. We’ll add a text field for editing the products tags and virtual attributes can help us to do this. First we’ll add a new text field to the form.
<div class="field"> <%= f.label :tag_names, "Tags (space separated)" %><br /> <%= f.text_field :tag_names %> </div>
Again we’ll add the new field to the attr_accessible
list in the model and we’ll need getter and setter methods. We’ll use attr_writer
to create the setter method but we need to customize the getter method.
attr_accessible :name, :price_in_dollars, :released_at_text, :category_id, :new_category, :tag_names attr_writer :tag_names def tag_names @tag_names || tags.pluck(:name).join(' ') end
This getter returns either the tag names that the user set or, if these aren’t set, the product’s current tags’ name
attributes joined by a space. We use Rails 3.2’s new pluck
method to get these. When we reload the page now we’ll see the new text field containing the product’s tags. When we change one of the names in the tag list now and submit the form the products tags will be updated.
Any changes to this field need to be picked up in a callback. We’ll create another before_save
callback called save_tag_names
.
before_save :save_tag_names def save_tag_names if @tag_names self.tags = @tag_names.split.map { |name| Tag.where(name: name).first_or_create! } end end
This method checks to see if the tag names are set and, if so, will set the tags for the product based on the names in the text field. We split the text field’s value on the space character and then for each tag name we find or create a new tag with that name.
As we’ve shown here virtual attributes are quite powerful and there are many ways we can use them. Things get a little tricky when we have to deal with validations but this complexity can be kept within the model layer. On a similar note if you’re interested in adding autocompletion behaviour to association fields take a look at episodes 258 and 102.