Extending Active Record with acts_as

Problem

You may have used the Active Record acts extensions that ship with Rails, such as acts_as_list, or those added by plug-ins, such as acts_as_versioned. But you really need your own acts functionality. For example, you would like each object of a Word model to have a method called define that returns that word's definition. You want to create acts_as_dictionary.

Solution

To create a custom plug-in, use the plug-in generator. The generator creates a number of files and directories that form the base for a distributable plug-in. Note that not all of these files have to be included.

$ ./script/generate plugin acts_as_dictionary
create vendor/plugins/acts_as_dictionary/lib create vendor/plugins/acts_as_dictionary/tasks create vendor/plugins/acts_as_dictionary/test create vendor/plugins/acts_as_dictionary/README create vendor/plugins/acts_as_dictionary/Rakefile create vendor/plugins/acts_as_dictionary/init.rb create vendor/plugins/acts_as_dictionary/install.rb create vendor/plugins/acts_as_dictionary/lib/acts_as_dictionary.rb create vendor/plugins/acts_as_dictionary/tasks/acts_as_dictionary_tasks.rake create vendor/plugins/acts_as_dictionary/test/acts_as_dictionary_test.rb

Now, add the following to init.rb to load lib/acts_as_dictionary.rb when you restart your application:

vendor/plugins/acts_as_dictionary/init.rb:

require 'acts_as_dictionary'
ActiveRecord::Base.send(:include, ActiveRecord::Acts::Dictionary)

To make the acts_as_dictionary method add methods to a model and its instance objects, you must open the module definitions of Rails and add your own method definitions. Add a define instance method and a dictlist class method to all models that are to act as dictionaries by adding the following module definitions to acts_as_dictionary.rb:

vendor/plugins/acts_as_dictionary/lib/acts_as_dictionary.rb:

require 'active_record'
require 'rexml/document'
require 'net/http'
require 'uri'
module Cookbook
 module Acts
 module Dictionary
 def self.included(mod)
 mod.extend(ClassMethods)
 end
 module ClassMethods
 def acts_as_dictionary
 class_eval do
 extend Cookbook::Acts::Dictionary::SingletonMethods
 end
 include Cookbook::Acts::Dictionary::InstanceMethods
 end
 end
 module SingletonMethods
 def dictlist
 base = "http://services.aonaware.com"
 url = "#{base}/DictService/DictService.asmx/DictionaryList?"
 begin
 dict_xml = Net::HTTP.get URI.parse(url)
 doc = REXML::Document.new(dict_xml)
 dictionaries = []
 hash = {}
 doc.elements.each("//Dictionary/*") do |elem|
 if elem.name == "Id" 
 if !hash.empty?
 dictionaries << hash 
 hash = {}
 end
 hash[:id] = elem.text
 else
 hash[:name] = elem.text
 end
 end
 dictionaries
 rescue
 "error"
 end
 end
 end
 module InstanceMethods
 def define(dict='foldoc')
 base = "http://services.aonaware.com"
 url = "#{base}/DictService/DictService.asmx/DefineInDict"
 url << "?dictId=#{dict}&word=#{self.name}"
 begin
 dict_xml = Net::HTTP.get URI.parse(url)
 REXML::XPath.first(REXML::Document.new(dict_xml), 
 '//Definition/WordDefinition').text.gsub(/(\n|\s+)/,' ')
 rescue
 "no definition found"
 end
 end
 end
 end
 end end ActiveRecord::Base.class_eval do
 include Cookbook::Acts::Dictionary end

To demonstrate that the plug-in works, create a words table with a migration that simply contains a name column. Next, generate the Word model for this table:

db/migrate/001_create_words.rb:

class CreateWords < ActiveRecord::Migration
 def self.up
 create_table :words do |t|
 t.column :name, :string 
 end
 end
 def self.down
 drop_table :words
 end end

Now add your custom method to the Word class by calling acts_as_dictionary in the model class definition just as you would with the built-in acts:

app/models/word.rb:

class Word < ActiveRecord::Base
 acts_as_dictionary end

Calling Word.dictlist returns an array of hashes containing all of the service's available dictionaries of the web service DictService (). Word objects can be defined by calling their define method, which takes a dictionary ID (from the results of dictlist) as an optional parameter.

Discussion

There's a lot of idiomatic Ruby happening in acts_as_dictionary.rb. The basic premise behind extending Ruby in this way is the concept of open classes: the fact that a Ruby class can be extended at any time.

The module starts out by including active_record and several other libraries used for HTTP requests and XML manipulation. Three module definitions are then opened to set up a namespace:

module Cookbook
 module Acts
 module Dictionary

Next, the included method is defined. This method is a callback method that gets invoked whenever the receiver is included in another module (or class).

def self.included(mod)
 mod.extend(ClassMethods)
end

In this case, included extends ActiveRecord::Base to include the ClassMethods module. In turn, the call to class_eval at the end of the file makes sure that ActiveRecord::Base includes Cookbook::Acts::Dictionary:

ActiveRecord::Base.class_eval do
 include Cookbook::Acts::Dictionary end

The ClassMethods module defines the acts_as_dictionary method that you'll use to attach the dictionary behavior to the models of your Rails application:

module ClassMethods
 def acts_as_dictionary
 class_eval do
 extend Cookbook::Acts::Dictionary::SingletonMethods
 end
 include Cookbook::Acts::Dictionary::InstanceMethods
 end end

The first part of the acts_as_dictionary method definition evaluates a call to extend. This makes all of the methods of the Cookbook::Acts::Dictionary::SingletonMethods module class methods of the receiver of acts_as_dictionary. The next line simply includes the methods in Cookbook::Acts::Dictionary::InstanceMethods as instance methods of the receiving model. The end result is that a model that acts as dictionary gets a class method, dictlist and an instance method, define. dictlist by polling a dictionary web service and calling its DictionaryList. This action returns a list of available dictionaries. The define method take the ID of a dictionary (as returned from dictlist) and returns the definition of the word, if found.

Here's the result of calling the dictlist method of the Word class, which returns an array of hashes, and printing the hashes out in somewhat nicer format:

>> Word.dictlist.each {|d| puts "ID: " + d[:id], "NAME: " + d[:name], "" }
ID: gcide NAME: The Collaborative International Dictionary of English v.0.48
ID: wn NAME: WordNet (r) 2.0
ID: moby-thes NAME: Moby Thesaurus II by Grady Ward, 1.0
ID: elements NAME: Elements database 20001107
ID: vera NAME: Virtual Entity of Relevant Acronyms (Version 1.9, June 2002)
ID: jargon NAME: Jargon File (4.3.1, 29 Jun 2001)
ID: foldoc NAME: The Free On-line Dictionary of Computing (27 SEP 03)

To look up a word in the dictionary, create a Word object with a :name of "Berkelium", an element from the periodic table. To display the definition, call define on the Word object and explicitly specify the 'elements' dictionary:

>> w = Word.create(:name => 'Berkelium')
=> #<Word:0x239ce18 @errors=#<ActiveRecord::Errors:0x239b784 @errors={}, 
@base=#<Word:0x239ce18 ...>>, @attributes={"name"=>"Berkelium", "id"=>11}, 
@new_record=false>
>> w.define('elements')
=> "berkelium Symbol: Bk Atomic number: 97 Atomic weight: (247) Radioactive 
metallic transuranic element. Belongs to actinoid series. Eight known isotopes, 
the most common Bk-247, has a half-life of 1.4*10^3 years. First produced by 
Glenn T. Seaborg and associates in 1949 by bombarding americium-241 with alpha 
particles."

From the Rails console, you can inspect the class and instance methods of the module:

>> ActiveRecord::Acts::Dictionary::InstanceMethods::\
 ClassMethods.public_instance_methods
=> ["dictlist"]
>> ActiveRecord::Acts::Dictionary::InstanceMethods.public_instance_methods
=> ["define"]

See Also