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
enddef 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
endend
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::Basedef 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::Basedef 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::Basedef 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 :peopledef 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 :-)