Topic: HOWTO: Make A Rails Plugin From Scratch

PLUGIN: default_find_options

Ahoy!
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:

class Person < ActiveRecord::Base
end
Person.find(:all) # random order
Person.find(:all, :order => 'age') # ordered by age

How things will work after this plugin:
class Person < ActiveRecord::Base
  default_find_option :order, :age
end
Person.find(:all) # ordered by age!
Person.default_find_option :order, nil
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

$ rails make_a_plugin
      create
      create  app/controllers
      create  app/helpers
      create  app/models
      create  app/views/layouts
.....
      create  public/javascripts/application.js
      create  doc/README_FOR_APP
      create  log/server.log
      create  log/production.log
      create  log/development.log
      create  log/test.log

$ 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.

$ ruby script/generate model person
      exists  app/models/
      exists  test/unit/
      exists  test/fixtures/
      create  app/models/person.rb
      create  test/unit/person_test.rb
      create  test/fixtures/people.yml
      create  db/migrate
      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:
class CreatePeople < ActiveRecord::Migration
  def self.up
    create_table :people do |t|
      t.column :name,   :string
      t.column :age,    :integer
      t.column :gender, :string
    end
  end

  def self.down
    drop_table :people
  end
end


And now we need to create this model's table in our database:
$ rake migrate
== CreatePeople: migrating ====================================================
-- create_table(:people)
   -> 0.0040s
== 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.
$ ruby script/generate plugin ar_default_options
      create  vendor/plugins/ar_default_options/lib
      create  vendor/plugins/ar_default_options/tasks
      create  vendor/plugins/ar_default_options/test
      create  vendor/plugins/ar_default_options/README
      create  vendor/plugins/ar_default_options/Rakefile
      create  vendor/plugins/ar_default_options/init.rb
      create  vendor/plugins/ar_default_options/install.rb
      create  vendor/plugins/ar_default_options/lib/ar_default_options.rb
      create  vendor/plugins/ar_default_options/tasks/ar_default_options_tasks.rake
      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:

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:

class << ActiveRecord::Base
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.

class << ActiveRecord::Base

  # define a method for this class that takes two arguments.
  def default_find_option(option_name, value)
    # set our instance variable to a Hash if it's currently nil
    @default_find_options ||= {}
    # and add our information to it.
    @default_find_options[option_name] = value
  end

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):

Person.default_find_option :order, :age
# we can also do it this way:
class Person < ActiveRecord::Base
  default_find_option :conditions, "gender = 'Female'"
end
# let's check that that actually did something:
Person.instance_variable_get "@default_find_options"
# => {: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. 

class << ActiveRecord::Base

  def default_find_option(option_name, value)
    @default_find_options ||= {}
    @default_find_options[option_name] = value
  end
 
  # re-define the 'find' method.  It takes the same arguments as the original.
  def find(*args)
    # this is just a way Rails finds the options in the arguments given (not important to us)
    options = args.is_a?(Hash) ? args.pop : {}
    # make sure our storage container isn't set to nil.
    @default_find_options ||= {}
    # call the find method to load up all the requested records.
    # the merge method is a way to combine hashes.
    find(@default_find_options.merge(options))
  end
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.

class << ActiveRecord::Base

  def default_find_option(option_name, value)
    @default_find_options ||= {}
    @default_find_options[option_name] = value
  end
 
  # make a backup of 'find' under the name 'orig_find'
  alias_method :orig_find, :find
  def find(*args)
    options = args.is_a?(Hash) ? args.pop : {}
    @default_find_options ||= {}
    orig_find(@default_find_options.merge(options))
  end
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.

class << ActiveRecord::Base

  def default_find_option(option_name, value)
    @default_find_options ||= {}
    @default_find_options[option_name] = value
  end
   
  private
  alias_method :orig_find_every, :find_every
  def find_every(*args)
    options = args.is_a?(Hash) ? args.pop : {}
    @default_find_options ||= {}
    orig_find_every(@default_find_options.merge(options))
  end
 
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:

jane:
  id:     1
  name:   Jane
  age:    25
  gender: Female
mike:
  id:     2
  name:   Mike
  age:    13
  gender: Male
kate:
  id:     3
  name:   Kate
  age:    44
  gender: Female
bryan:
  id:     4
  name:   Bryan
  age:    26
  gender: Male

And put the following into 'test/unit/person_test.rb':
require File.dirname(__FILE__) + '/../test_helper'

class PersonTest < Test::Unit::TestCase
  fixtures :people

  def setup
      # empty out our options before each test
      Person.instance_variable_set "@default_find_options", {}
  end
 
  def test_default_order
    assert_equal [1,2,3,4], Person.find(:all).collect {|p| p.id}
    assert_equal 1, Person.find(:first).id
  end
 
  def test_ordered_by_age
    Person.default_find_option :order, :age
    assert_equal [2,1,4,3], Person.find(:all).collect {|p| p.id}
    assert_equal 2, Person.find(:first).id
  end
 
  def test_ordered_by_gender
    Person.default_find_option :order, :gender
    assert_equal [1,3,2,4], Person.find(:all).collect {|p| p.id}
  end
 
  def test_only_find_males
    Person.default_find_option :conditions, "gender = 'Male'"
    assert_equal [2,4], Person.find(:all).collect {|p| p.id}
    assert_equal 2, Person.find(:first).id
  end
 
  def test_only_find_first_three
    Person.default_find_option :limit, 3
    assert_equal [1,2, 3], Person.find(:all).collect {|p| p.id}
  end
 
end


And now for the great reckoning, type 'rake test:units' into the command line and see what comes out:
$ rake test:units 2> /dev/null
        (in /www/hosts/make_a_plugin)
        Loaded suite /usr/lib/ruby/gems/1.8/gems/rake-0.7.1/lib/rake/rake_test_loader
        Started
        .....
        Finished in 0.076188 seconds.
       
        5 tests, 8 assertions, 0 failures, 0 errors

Conclusion

It should be pointed out that this is very nearly the simplest plugin possible.  There are all kinds of excellent enhancements that you can give to a plugin ranging from the highly useful (like real tests) to those that are simply fun (an message that pops up to folks when they install it).  I recommend checking out the following resources if you'd like to pursue this further:

TopFunky's introduction to plugins 1 2

Rick Olson's (techno-weenie's) incredible plugin collection.

If you'd like to browse the code to the tutorial plugin it's available here:
http://svn.6brand.com/projects/plugins/ … lt_options
And if, for some reason, you'd like to use this in any of your applications you can install it quite easily:

ruby script/plugin install -x http://svn.6brand.com/projects/plugins/ … lt_options

Please leave a comment if you notice a typo or if you want help with anything.  I'm getting maried in a little over a week and then I'm gone for a month so ask quickly :-)

Re: HOWTO: Make A Rails Plugin From Scratch

Awesome tutorial! And the example plugin is actually useful, woohoo! I love it when examples are useful. smile

Just one note, I don't think the switch from find to find_every is insignificant. I haven't done any testing, but I don't think overriding "find" like that will work. What about the options which aren't part of a hash? Such as :all, :first, and ids?

I think overriding find_every's the right way to go from the start.

Railscasts - Free Ruby on Rails Screencasts

Re: HOWTO: Make A Rails Plugin From Scratch

Yeah, I guess 'insignificant' isn't quite the right word for it.  More like, "something you wouldn't normally need until way later when it would kick your ass".

"Find" is an interesting method.  It can be overwridden just as in the example above but all it really does is look at the first argument and, based on that, sends the options to one of three other methods.  ALL of which eventually end up in "find_every".

      def find(*args)
        options = extract_options_from_args!(args)
        validate_find_options(options)
        set_readonly_option!(options)

        case args.first
          when :first then find_initial(options)
          when :all   then find_every(options)
          else             find_from_ids(args, options)
        end
      end


I think I tried it with just "find" to see it if would work and it was highly functional.  Didn't want to leave people with a broken plugin though :-)

Re: HOWTO: Make A Rails Plugin From Scratch

Really nice and interesting tutorial! I didn't know that writing his own plugin can be as easy. Thanks for that danger.

My homepage: http://www.komendera.com/
Working at: http://www.abloom.at/
My blog: soaked and soaped http://soakedandsoaped.com/

Re: HOWTO: Make A Rails Plugin From Scratch

danger wrote:

I think I tried it with just "find" to see it if would work and it was highly functional.  Didn't want to leave people with a broken plugin though :-)

It's just that find expects the first argument to be either :first, :all, or an id, but you are sending a hash as the first argument in the example:

danger wrote:

orig_find(@default_find_options.merge(options))

So I'm not sure how that would work. find_every takes a hash so there's no problem there. I guess it's not that big of a deal since the end result works.

Railscasts - Free Ruby on Rails Screencasts

Re: HOWTO: Make A Rails Plugin From Scratch

Thanks for another great Tutorial

Added it to my growing (140+) below.

Last edited by boyles (2006-09-25 18:09:04)

Re: HOWTO: Make A Rails Plugin From Scratch

Great tutorial.  Take it one step further and explain how to publish and advertise your plugin.  That part is usually left out of these types of tutorials.

Re: HOWTO: Make A Rails Plugin From Scratch

nice tutorial - its really good to found this howto since I need to write my own plugin

Re: HOWTO: Make A Rails Plugin From Scratch

sethladd wrote:

Great tutorial.  Take it one step further and explain how to publish and advertise your plugin.  That part is usually left out of these types of tutorials.

Agreed! Both about this being an excelent tutorial and that it would be helpfull if you explained how to publish and advertise our plugins. Either way, keep up the good work!

Re: HOWTO: Make A Rails Plugin From Scratch

I'm not really sure how much there is to say about publishing your plugins.  Basically it's a two-step process:
1) Host your plugin in an svn repository somewhere.
2) Announce the plugin on your blog.

If it's useful, people will use it.  If it's not - well, at least you can play around with it.

Re: HOWTO: Make A Rails Plugin From Scratch

This one is good if you want to do some modification to the find method, but what about tutorial how to do login plugin or something similar?

I couldn't manage to find any tutorials telling how to do it? 


And Thanks

Re: HOWTO: Make A Rails Plugin From Scratch

Heya, thanks a ton.

Re: HOWTO: Make A Rails Plugin From Scratch

I was having a few problems with this plugin (I pulled from your repository, thank you.)

1. find(params[:id]), not an uncommon task at all, was translating to "SELECT * FROM my_table ORDER BY my_column", leaving out the ID altogether. This appears to be a bug, which I fixed by rewriting to use with_scope instead.
2. (pretty minor) I'd like to use hash syntax to allow me to pass multiple in one default_find_option call.

I managed this by changing lib/ar_default_options.rb to:

class << ActiveRecord::Base

  def default_find_option(*args)
    @default_find_options ||= {}
   
    if args[0].is_a?(Hash)
      @default_find_options.merge!(args[0])
    else
      option_name, value = *args
      @default_find_options[option_name] = value
    end
  end
   
  private
  alias_method :orig_find_every, :find_every
  def find_every(*args)
    with_scope :find => (@default_find_options || {}) do
      orig_find_every(*args)
    end
  end
end


It seems to work fine for me, and I think with_scope makes things a bit cleaner. Do you see any issues with this implementation? (I also tried to use alias_method_chain instead of alias_method, but Rails didn't like that for some reason.)

Re: HOWTO: Make A Rails Plugin From Scratch

Very interesting post, and also very innovative. I appreciate your effort, I followed each and every step dictated here.

Person.find(:all)  shows the sorted people according to their ages but when i tried dynamic

finders like find_by_name, find_by_gender gives me wrong records

Person.find_by_name doesn't worked correctly??? #sad#
sad sad

Last edited by vibha (2008-10-22 08:36:24)

15

Re: HOWTO: Make A Rails Plugin From Scratch

OK, I've been staring at this long enough, and it looks like manitoba98 may have already addressed this with his/her version of #find_every, but I'll go ahead and ask anyway, just because I'm curious about this now.

How is this code supposed to work?  Specifically, the code that reads:

   options = args.is_a?(Hash) ? args.pop : {}

if args is a Hash, you're going invoke the #pop method on it?  But Hash doesn't have a #pop method... does it?  Does Rails add a #pop method to Hash?  Or, more accurately, did it used to at one time?  (Since it doesn't seem to do that now).

I wondered about this as I went through the tutorial myself and thought, "Well, perhaps I'll learn something here."  But when my code failed its tests (my own code in which I dropped this plugin) I went back to the bit that I didn't understand.

And I still don't understand.

manitoba98's version makes this go away, but I'm left curious.

--wpd

Re: HOWTO: Make A Rails Plugin From Scratch

hi, it's helpful. thanks a lot!

Re: HOWTO: Make A Rails Plugin From Scratch

thanks..!




http://www.creativenexusonline.com

Last edited by blackwidow (2010-08-18 00:49:57)

Re: HOWTO: Make A Rails Plugin From Scratch

Great tut, all is working fine for me smile

Re: HOWTO: Make A Rails Plugin From Scratch

All necessary tutorial are here. Thanks a lot .

Last edited by andrebrewersag (2013-05-26 13:19:18)

Re: HOWTO: Make A Rails Plugin From Scratch

Thanks for this great solution!