33: Making a Plugin
(view original Railscast)
In the last episode we showed how to edit a datetime column with a text field, rather than with a series of dropdown menus. The date and time for a task were entered in a text field then parsed as a date and time before being stored in the database.
We did this by creating a virtual attribute in our Task
model called due_at_string
. This attribute has a getter method that displays the date in a format that is suitable for storing in the database and a setter method that parses the string to turn it back into a date.
def due_at_string due_at.to_s(:db) end def due_at_string=(due_at_str) self.due_at = Time.parse(due_at_str) rescue ArgumentError @due_at_invalid = true end
This approach works if there’s only one attribute we want to modify this way, but if there are several then we’ll quickly have a lot of duplication in the model as we create getter and setter methods for each attribute.
Instead of this we’re going to create a class method in our model, which we’ll call stringify_time
. This method will dynamically generate getter and setter methods for any attribute we pass to it. As this is something we may well want to use in other applications we’ll develop this as a plugin.
Creating a Plugin
To start we’ll generate an empty plugin called stringify_time
. To do this we run
script/generate plugin stringify_time
from our application’s directory. This will generate a number of files in a new stringify_time
directory under /vendor/plugins
.
We’ll look at the init.rb
file first. This file is loaded when the plugin loads, so here we’ll require
the file in the lib directory where we’ll develop the plugin’s functionality.
require 'stringify_time'
The stringify_time.rb
file is where we’ll write the code that generates getter and setter methods similar to the due_at_string
methods we used earlier. We’ll start by defining a module with a stringify_time
method.
module StringifyTime def stringify_time(*names) end end
The stringify_time
method takes a list of names as a parameter. We’ve used the “splat” mark to indicate that the method can take a number of arguments, each of which will be put into an array called names
.
The method will need to loop through the names
array and create two methods for each name, a getter and a setter. Ruby makes this kind of metaprogramming easy; all we need to do to dynamically create a method in a class is to call define_method
. The code to create the getter methods is:
names.each do |name| define_method "#{name}_string" do read_attribute(name).to_s(:db) end end
This code loops through each name in the array and uses define_method
to dynamically create a method whose name is the passed name with _string
appended to it, so if we pass due_at
we’ll get a new due_at_string
method. define_method
takes a block, the code within the block becoming the body of the method. The due_at_string
method we created earlier took the value of the model’s due_at
attribute and returned it as a formatted string. We do the same here, but as our attribute is dynamic we have to use read_attribute
to get the value.
With the getter defined we can now write the setter method.
define_method "#{name}_string=" do |time_str| begin write_attribute(name, Time.parse(time_str)) rescue ArgumentError instance_variable_set("@#{name}_invalid", true) end end
We use define_method
again here. As we’re creating a setter method the method name ends in an equals sign and, as it needs to take a parameter, we define that as a block variable.
Our due_at_string=
method parses the string passed to it and converts it to a Time
value, then sets due_at
to that value. If the value cannot be parsed, the exception is caught and a instance variable called @due_at_invalid
is set to true
. In our dynamic setter we use write_attribute
to set the dynamic attribute instead and if that fails call instance_variable_set
to set the corresponding instance variable.
Putting the pieces above together, our StringifyTime
module looks like this:
module StringifyTime def stringify_time(*names) names.each do |name| define_method "#{name}_string" do read_attribute(name).to_s(:db) end define_method "#{name}_string=" do |time_str| begin write_attribute(name, Time.parse(time_str)) rescue ArgumentError instance_variable_set("@#{name}_invalid", true) end end end end end
There is one more change we’ll have to make before our plugin will work. We’ll be calling stringify_time
in model classes that inherit from ActiveRecord::Base
, so we’ll have to extend ActiveRecord with our new module. Back in init.rb
we can do that by adding
class ActiveRecord::Base extend StringifyTime end
Note that we’re using extend
rather than include
as that makes the method in our module a class method rather than an instance method.
Now that we’ve defined our plugin we can use it in our Task
model, replacing the getter and setter methods with a call to stringify_time
.
class Task < ActiveRecord::Base belongs_to :project stringify_time :due_at def validate errors.add(:due_at, "is invalid") if @due_at_invalid end end
Before we refresh the edit task page to see if our plugin works we’ll have to restart the web server. With that done we can refresh the page and see if it all works.
It seems to be OK. The task’s time has been displayed correctly in the time field. Let’s now try entering an invalid value and seeing if that is handled correctly.
That works too, but the validation is only working because we’re using the correct instance variable name from the plugin to detect whether the model is valid or not.
def validate errors.add(:due_at, "is invalid") if @due_at_invalid end
It seems a little ugly to be relying on an instance variable generated by a plugin so instead we’ll use a method.
def validate errors.add(:due_at, "is invalid") if due_at_invalid? end
We’ll have to generate one more dynamic method in stringify_time.rb
to create this method. Immediately below the code that creates the dynamic getter and setter methods we can add this
define_method "#{name}_is_invalid?" do return instance_variable_get("@#{name}_invalid") end
to create the _invalid?
method.
And that’s it. We have succesfully created our first Rails plugin. While it’s a fairly basic one, the principle is the same no matter how complex a plugin you need to create.