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
How things will work after this plugin:- class Person < ActiveRecord::Base
- end
- Person.find(:all) # random order
- Person.find(:all, :order => 'age') # ordered by age
Code : ruby - fold - unfold
This is in response to an open ticket on the Ruby on Rails dev site.- 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!
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
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.- $ 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
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
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:- $ 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
Code : ruby - fold - unfold
And now we need to create this model's table in our database:- 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
Code : bash - fold - unfold
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.- $ rake migrate
- == CreatePeople: migrating ====================================================
- -- create_table(:people)
- -> 0.0040s
- == CreatePeople: migrated (0.0044s) ===========================================
Code : bash - fold - unfold
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:- $ 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
Code : ruby - fold - unfold
Modify the way ActiveRecord Works- require 'ar_default_options'
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
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.- class << ActiveRecord::Base
- end
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
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):- 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
Code : ruby - fold - unfold
So we've got the values saved in there. Now we need to figure out what we're going to do with them.- 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}
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
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?- 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
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
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.- 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'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
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.- 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
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
And put the following into 'test/unit/person_test.rb':- 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
Code : ruby - fold - unfold
And now for the great reckoning, type 'rake test:units' into the command line and see what comes out:- 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
Code : ruby - fold - unfold
- $ 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
discuss this topic to forum
