• home
  • forum
  • my
  • kt
  • download
  • HOWTO: Make a Rails Plugin From Scratch

    Author: 2007-08-25 16:51:04 From:

    Rails has a lot of features but the core team is very cautious about adding any new functionality.  Part of what has made it such a good framework is that they don't allow features in that aren't necessary or highly useful.  This means that most of the cool add-ons we'd like to see have to end up as plugins.  And plugins are really hard to make, right?  Well, if you follow along with this tutorial you'll be a plugin author in just a few minutes.

    What we're going to do is create a new Rails plugin that allows certain models to specify how they are loaded from the database by default.

    The Concept
    How things work now:

    Code :  ruby - fold - unfold
    1. class Person < ActiveRecord::Base
    2. end
    3. Person.find(:all) # random order
    4. Person.find(:all, :order => 'age') # ordered by age
    How things will work after this plugin:

    Code :  ruby - fold - unfold
    1. class Person < ActiveRecord::Base
    2.   default_find_option :order, :age
    3. end
    4. Person.find(:all) # ordered by age!
    5. Person.default_find_option :order, nil
    6. Person.find(:all) # back to random ordering!
    This is in response to an open ticket on the Ruby on Rails dev site.

    Things to know for this tutorial:
      - Lines that start with a $ are things you'll need to type into a command line
      - You'll be needing to set up your own databases, everything else will be step-by-step
      - This plugin won't have any built-in testing (that's much more complicated).  We'll be using the application's tests

    Lay a Foundation

    Code :  bash - fold - unfold
    1. $ rails make_a_plugin
    2.       create
    3.       create  app/controllers
    4.       create  app/helpers
    5.       create  app/models
    6.       create  app/views/layouts
    7. .....
    8.       create  public/javascripts/application.js
    9.       create  doc/README_FOR_APP
    10.       create  log/server.log
    11.       create  log/production.log
    12.       create  log/development.log
    13.       create  log/test.log
    14.  
    15. $ cd make_a_plugin
    You just created a rails app and stepped into it.  Now I'll need you to edit your config/database.yml file to point to a valid development database and a valid test database.  You can ignore the production one.

    We're going to create a model that we can run our tests on.  To continue the example from above we'll make it a Person model.


    Code :  bash - fold - unfold
    1. $ ruby script/generate model person
    2.       exists  app/models/
    3.       exists  test/unit/
    4.       exists  test/fixtures/
    5.       create  app/models/person.rb
    6.       create  test/unit/person_test.rb
    7.       create  test/fixtures/people.yml
    8.       create  db/migrate
    9.       create  db/migrate/001_create_people.rb
    Now you've got a model set up.  Go ahead and edit the file "db/migrate/001_create_people.rb" and just copy and paste the following into it:

    Code :  ruby - fold - unfold
    1. class CreatePeople < ActiveRecord::Migration
    2.   def self.up
    3.     create_table :people do |t|
    4.       t.column :name,   :string
    5.       t.column :age,    :integer
    6.       t.column :gender, :string
    7.     end
    8.   end
    9.  
    10.   def self.down
    11.     drop_table :people
    12.   end
    13. end 
    And now we need to create this model's table in our database:

    Code :  bash - fold - unfold
    1. $ rake migrate
    2. == CreatePeople: migrating ====================================================
    3. -- create_table(:people)
    4.    -> 0.0040s
    5. == CreatePeople: migrated (0.0044s) ===========================================
    Now we've got a barebones Rails app.  It won't do much on it's own but it's enough to allow us to build a plugin to modify it.  The next step is to generate a plugin.  It's every bit as simple as it should be.  Because this modifies ActiveRecord and it involves default options I've decided to call it "ar_default_options".  You can be more clever if you like.

    Code :  bash - fold - unfold
    1. $ ruby script/generate plugin ar_default_options
    2.       create  vendor/plugins/ar_default_options/lib
    3.       create  vendor/plugins/ar_default_options/tasks
    4.       create  vendor/plugins/ar_default_options/test
    5.       create  vendor/plugins/ar_default_options/README
    6.       create  vendor/plugins/ar_default_options/Rakefile
    7.       create  vendor/plugins/ar_default_options/init.rb
    8.       create  vendor/plugins/ar_default_options/install.rb
    9.       create  vendor/plugins/ar_default_options/lib/ar_default_options.rb
    10.       create  vendor/plugins/ar_default_options/tasks/ar_default_options_tasks.rake
    11.       create  vendor/plugins/ar_default_options/test/ar_default_options_test.rb
    The only detail left to do before we get into some code is to hook this plugin up so it's automatically included in our application.  To do this, edit the "vendor/plugins/ar_default_options/init.rb" file to look like this:


    Code :  ruby - fold - unfold
    1. require 'ar_default_options'
    Modify the way ActiveRecord Works

    Pretty much all of our work will happen in just one file: "vendor/plugins/ar_default_options/lib/ar_default_options.rb".

    Open it and copy the following into the file:

    Code :  ruby - fold - unfold
    1. class << ActiveRecord::Base
    2. end 
    Congratulations, you've just opened up the guts of Rails and reached your hand inside. We haven't done anything yet, but it's significant to know that we just opened up one of the most essential pieces of Rails code and we could add ANYTHING we want to it.  Ignore the 'class <<' notation for now.

    Now, our goal is to be able to specify certain values that will be used as defaults for the model whenever it calls the 'find' method on a given model.  To do this we'll need some way of storing these values.  But not just storing them any old place; we need to satisfy the following criteria:
      - the values should be set in the class definition before any instances are created
      - the values should be unique for each class/model (i.e. one table's values shouldn't effect another's)

    It turns out that there's a specific way Ruby lets us do this.  We're going to use the class's 'singleton class'.  Basically we'll be able to use @-styled variables and set up method definitions that can be used for the class itself - not for instances of the class.  The 'class <<' notation is the way Ruby lets us do this.


    Code :  ruby - fold - unfold
    1. class << ActiveRecord::Base
    2.  
    3.   # define a method for this class that takes two arguments.
    4.   def default_find_option(option_name, value)
    5.     # set our instance variable to a Hash if it's currently nil
    6.     @default_find_options ||= {}
    7.     # and add our information to it.
    8.     @default_find_options[option_name] = value
    9.   end
    10.  
    11. end 
    There.  We've now got a class method that lets us assign values to any ActiveRecord model and they'll stay put.  Let's try it out (you can type this into irb or just read along):


    Code :  ruby - fold - unfold
    1. Person.default_find_option :order, :age
    2. # we can also do it this way:
    3. class Person < ActiveRecord::Base
    4.   default_find_option :conditions, "gender = 'Female'"
    5. end
    6. # let's check that that actually did something:
    7. Person.instance_variable_get "@default_find_options"
    8. # => {:conditions=>"gender = 'Female'", :order=>:age}
    So we've got the values saved in there.  Now we need to figure out what we're going to do with them.

    Since we're trying to emulate the same functionality as when someone calls Person.find(:all, :order => :age) we need some way to throw the information we've collected at the find method.  It turns out that the best way to do that is to create our own find method that jumps in front of the old one.  We're going to re-route all calls to Person.find to our own method. 

    Code :  ruby - fold - unfold
    1. class << ActiveRecord::Base
    2.  
    3.   def default_find_option(option_name, value)
    4.     @default_find_options ||= {}
    5.     @default_find_options[option_name] = value
    6.   end
    7.   
    8.   # re-define the 'find' method.  It takes the same arguments as the original.
    9.   def find(*args)
    10.     # this is just a way Rails finds the options in the arguments given (not important to us)
    11.     options = args.is_a?(Hash) ? args.pop : {}
    12.     # make sure our storage container isn't set to nil.
    13.     @default_find_options ||= {}
    14.     # call the find method to load up all the requested records.
    15.     # the merge method is a way to combine hashes.
    16.     find(@default_find_options.merge(options))
    17.   end
    18. end 
    Now we run into a different problem.  The last line of our method calls itself!  That would put us into an infinite loop.  So how do we do all that database-y stuff that the original find method did?  Do we have to copy-and-paste the whole original method into ours or is there some way of still getting to the original?

    Ruby offers many ways to override or add-on to methods.  We're going to go with a rather odd one that just happens to be the best for what we're doing.  'alias_method' is a way of copying some method to a new name.  It's great for making a backup of methods that we're about to clobber.

    Code :  ruby - fold - unfold
    1. class << ActiveRecord::Base
    2.  
    3.   def default_find_option(option_name, value)
    4.     @default_find_options ||= {}
    5.     @default_find_options[option_name] = value
    6.   end
    7.   
    8.   # make a backup of 'find' under the name 'orig_find'
    9.   alias_method :orig_find, :find
    10.   def find(*args)
    11.     options = args.is_a?(Hash) ? args.pop : {}
    12.     @default_find_options ||= {}
    13.     orig_find(@default_find_options.merge(options))
    14.   end
    15. end 
    There we go, now we've successfully intercepted the call to ActiveRecord::Base.find and we didn't have to reinvent all the clever stuff that Rails does so well.

    There's one more (insignificant) thing to do.  If we just redefine 'find' then we won't have any say in how the records are loaded if any of the other find-like variations are used (e.g. Person.find_by_age).  It turns out that all these find-ey methods eventually call the 'find_every' method to do their dirty work.  So that's the one we actually want to overwrite.  And we're going to mark 'find_every' as a private method because that's how it's listed normally.

    Code :  ruby - fold - unfold
    1. class << ActiveRecord::Base
    2.  
    3.   def default_find_option(option_name, value)
    4.     @default_find_options ||= {}
    5.     @default_find_options[option_name] = value
    6.   end
    7.     
    8.   private
    9.   alias_method :orig_find_every, :find_every
    10.   def find_every(*args)
    11.     options = args.is_a?(Hash) ? args.pop : {}
    12.     @default_find_options ||= {}
    13.     orig_find_every(@default_find_options.merge(options))
    14.   end
    15.   
    16. end 
    And that's it!  This is a working plugin that allows you to specify defaults for how ActiveRecord loads records on a per-model basis.  But just to be sure (and because untested code is scary), let's do a little testing.


    Testing our plugin using a simple application

    Edit the 'test/fixtures/people.yml' file that was created when we generated our model and paste the following into it:

    Code :  yaml - fold - unfold
    1. jane:
    2.   id:     1
    3.   name:   Jane
    4.   age:    25
    5.   gender: Female
    6. mike:
    7.   id:     2
    8.   name:   Mike
    9.   age:    13
    10.   gender: Male
    11. kate:
    12.   id:     3
    13.   name:   Kate
    14.   age:    44
    15.   gender: Female
    16. bryan:
    17.   id:     4
    18.   name:   Bryan
    19.   age:    26
    20.   gender: Male
    And put the following into 'test/unit/person_test.rb':

    Code :  ruby - fold - unfold
    1. require File.dirname(__FILE__) + '/../test_helper'
    2.  
    3. class PersonTest < Test::Unit::TestCase
    4.   fixtures :people
    5.  
    6.   def setup
    7.       # empty out our options before each test
    8.       Person.instance_variable_set "@default_find_options", {}
    9.   end
    10.   
    11.   def test_default_order
    12.     assert_equal [1,2,3,4], Person.find(:all).collect {|p| p.id}
    13.     assert_equal 1, Person.find(:first).id
    14.   end
    15.   
    16.   def test_ordered_by_age
    17.     Person.default_find_option :order, :age
    18.     assert_equal [2,1,4,3], Person.find(:all).collect {|p| p.id}
    19.     assert_equal 2, Person.find(:first).id
    20.   end
    21.   
    22.   def test_ordered_by_gender
    23.     Person.default_find_option :order, :gender
    24.     assert_equal [1,3,2,4], Person.find(:all).collect {|p| p.id}
    25.   end
    26.   
    27.   def test_only_find_males
    28.     Person.default_find_option :conditions, "gender = 'Male'"
    29.     assert_equal [2,4], Person.find(:all).collect {|p| p.id}
    30.     assert_equal 2, Person.find(:first).id
    31.   end
    32.   
    33.   def test_only_find_first_three
    34.     Person.default_find_option :limit, 3
    35.     assert_equal [1,2, 3], Person.find(:all).collect {|p| p.id}
    36.   end
    37.   
    38. end 
    And now for the great reckoning, type 'rake test:units' into the command line and see what comes out:

    Code :  ruby - fold - unfold
    1. $ rake test:units 2> /dev/null
    2.         (in /www/hosts/make_a_plugin)
    3.         Loaded suite /usr/lib/ruby/gems/1.8/gems/rake-0.7.1/lib/rake/rake_test_loader
    4.         Started
    5.         .....
    6.         Finished in 0.076188 seconds.
    7.         
    8.         5 tests, 8 assertions, 0 failures, 0 errors

    discuss this topic to forum

    relation tutorial

    No relevant information

    Category

      Database Related (2)
      Getting Started (7)
      Helpers (4)
      Image Manipulation (2)
      Security (4)

    New

    Hot