homeASCIIcasts

33: Making a Plugin 

(view original Railscast)

Other translations: It

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.

Editing a time in a textfield.

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.

The files that are created when generating a plugin.

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.

The edit page now works with our plugin.

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.

The validation is working too.

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.