Rails Forum
Rails Work - the best place to post and find great Ruby on Rails jobs.
Username
Password

You are not logged in.

New Posts in this thread

#1 2006-09-22 10:06:25

danger
Moderator
From: Seattle
Registered: 2006-06-15
Posts: 922
Website

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:

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

Code :  bash - fold - unfold
  1. ruby script/plugin install -x http://svn.6brand.com/projects/plugins/ar_default_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 :-)

Offline

 

#2 2006-09-22 14:33:22

ryanb
Moderator
Registered: 2006-06-14
Posts: 6323
Website

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

Offline

 

#3 2006-09-22 17:09:36

danger
Moderator
From: Seattle
Registered: 2006-06-15
Posts: 922
Website

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


Code :  ruby - fold - unfold
  1.       def find(*args)
  2.         options = extract_options_from_args!(args)
  3.         validate_find_options(options)
  4.         set_readonly_option!(options)
  5.  
  6.         case args.first
  7.           when :first then find_initial(options)
  8.           when :all   then find_every(options)
  9.           else             find_from_ids(args, options)
  10.         end
  11.       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 :-)

Offline

 

#4 2006-09-23 13:59:21

Dieter Komendera
First Class
From: Zistersdorf, Austria
Registered: 2006-08-10
Posts: 104
Website

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.wollzelle.com/
My blog: soaked and soaped http://soakedandsoaped.com/

Offline

 

#5 2006-09-23 17:51:55

ryanb
Moderator
Registered: 2006-06-14
Posts: 6323
Website

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:


Code :  ruby - fold - unfold
  1. 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

Offline

 

#6 2006-09-25 20:28:36

boyles
Ticketholder
From: Cape Town
Registered: 2006-09-24
Posts: 21

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 22:09:04)

Offline

 

#7 2006-11-08 02:50:45

sethladd
Ticketholder
Registered: 2006-11-08
Posts: 1

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.

Offline

 

#8 2006-11-27 03:31:20

edxerx
Ticketholder
Registered: 2006-09-04
Posts: 7

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

Offline

 

#9 2007-01-11 04:08:48

octoberdan
Ticketholder
Registered: 2007-01-04
Posts: 15

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!

Offline

 

#10 2007-01-11 08:33:06

danger
Moderator
From: Seattle
Registered: 2006-06-15
Posts: 922
Website

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.

Offline

 

#11 2007-03-26 10:51:04

Jamal
Ticketholder
Registered: 2007-03-26
Posts: 1

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

Offline

 

#12 2007-09-05 21:04:27

Dougy
Ticketholder
Registered: 2007-09-05
Posts: 1
Website

Re: HOWTO: Make A Rails Plugin From Scratch

Heya, thanks a ton.


Know or want to learn Ruby? Then, visit the Ruby Guide.

Offline

 

#13 2007-09-09 20:27:48

manitoba98
Roughneck
Registered: 2007-08-30
Posts: 777

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:


Code :  ruby - fold - unfold
  1. class << ActiveRecord::Base
  2.  
  3.   def default_find_option(*args)
  4.     @default_find_options ||= {}
  5.     
  6.     if args[0].is_a?(Hash)
  7.       @default_find_options.merge!(args[0])
  8.     else
  9.       option_name, value = *args
  10.       @default_find_options[option_name] = value
  11.     end
  12.   end
  13.     
  14.   private
  15.   alias_method :orig_find_every, :find_every
  16.   def find_every(*args)
  17.     with_scope :find => (@default_find_options || {}) do
  18.       orig_find_every(*args)
  19.     end
  20.   end
  21. 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.)

Offline

 

#14 2008-10-22 12:26:52

vibha
Ticketholder
Registered: 2008-10-22
Posts: 1

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???

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

Offline

 

Board footer

Powered by PunBB
© Copyright 2002–2005 Rickard Andersson