ActiveModel: Make Any Ruby Object Feel Like ActiveRecord

Rails 2.3 has a ton of really nice functionality locked up in monolithic components. I've posted quite a bit about how we've opened up a lot of that functionality in ActionPack, making it easier to reuse the router, dispatcher, and individual parts of ActionController. ActiveModel is another way we've exposed useful functionality to you in Rails 3.

Before I Begin, The ActiveModel API

Before I begin, there are two major elements to ActiveModel. The first is the ActiveModel API, the interface that models must adhere to in order to gain compatibility with ActionPack's helpers. I'll be talking more about that soon, but for now, the important thing about the ActiveModel API is that your models can become ActiveModel compliant without using a single line of Rails code.

In order to help you ensure that your models are compliant, ActiveModel comes with a module called ActiveModel::Lint that you can include into your test cases to test compliance with the API:

class LintTest < ActiveModel::TestCase  
  include ActiveModel::Lint::Tests

  class CompliantModel
    extend ActiveModel::Naming

    def to_model
      self
    end

    def valid?()      true end
    def new_record?() true end
    def destroyed?()  true end

    def errors
      obj = Object.new
      def obj.[](key)         [] end
      def obj.full_messages() [] end
      obj
    end
  end

  def setup
    @model = CompliantModel.new
  end
end  

The ActiveModel::Lint::Tests provide a series of tests that are run against the @model, testing for compliance.

ActiveModel Modules

The second interesting part of ActiveModel is a series of modules provided by ActiveModel that you can use to implement common model functionality on your own Ruby objects. These modules were extracted from ActiveRecord, and are now included in ActiveRecord.

Because we're dogfooding these modules, you can be assured that APIs you bring in to your models will remain consistent with ActiveRecord, and that they'll continue to be maintained in future releases of Rails.

The ActiveModel comes with internationalization baked in, providing an avenue for much better community sharing around translating error messages and the like.

The Validations System

This was perhaps the most frustrating coupling in ActiveRecord, because it meant that people writing libraries for, say, CouchDB had to choose between painstakingly copying the API over, allowing inconsistencies to creep in, or just inventing a whole new API.

Validations have a few different elements.

First, declaring the validations themselves. You've seen the usage before in ActiveRecord:

class Person < ActiveRecord::Base  
  validates_presence_of :first_name, :last_name
end  

To do the same thing for a plain old Ruby object, simply do the following:

class Person  
  include ActiveModel::Validations

  validates_presence_of :first_name, :last_name

  attr_accessor :first_name, :last_name
  def initialize(first_name, last_name)
    @first_name, @last_name = first_name, last_name
  end
end  

The validations system calls readattributeforvalidation to get the attribute, but by default, it aliases that method to send, which supports the standard Ruby attribute system of attraccessor.

To use a more custom attribute lookup, you can do:

class Person  
  include ActiveModel::Validations

  validates_presence_of :first_name, :last_name

  def initialize(attributes = {})
    @attributes = attributes
  end

  def read_attribute_for_validation(key)
    @attributes[key]
  end
end  

Let's look at what a validator actually is. First of all, the validatespresenceof method:

def validates_presence_of(*attr_names)  
  validates_with PresenceValidator, _merge_attributes(attr_names)
end  

You can see that validatespresenceof is using the more primitive validateswith, passing it the validator class, merging in {:attributes => attributenames} into the options passed to the validator. Next, the validator itself:

class PresenceValidator < EachValidator  
  def validate(record)
    record.errors.add_on_blank(attributes, options[:message])
  end
end  

The EachValidator that it inherits from validates each attribute with the validate method. In this case, it adds the error message to the record, only if the attribute is blank.

The addonblank method does add(attribute, :blank, :default => custom_message) if value.blank? (among other things), which is adding the localized :blank message to the object. If you take a look at the built-in locale/en.yml looks like:

en:  
  errors:
    # The default format use in full error messages.
    format: "{{attribute}} {{message}}"

    # The values :model, :attribute and :value are always available for interpolation
    # The value :count is available when applicable. Can be used for pluralization.
    messages:
      inclusion: "is not included in the list"
      exclusion: "is reserved"
      invalid: "is invalid"
      confirmation: "doesn't match confirmation"
      accepted: "must be accepted"
      empty: "can't be empty"
      blank: "can't be blank"
      too_long: "is too long (maximum is {{count}} characters)"
      too_short: "is too short (minimum is {{count}} characters)"
      wrong_length: "is the wrong length (should be {{count}} characters)"
      not_a_number: "is not a number"
      greater_than: "must be greater than {{count}}"
      greater_than_or_equal_to: "must be greater than or equal to {{count}}"
      equal_to: "must be equal to {{count}}"
      less_than: "must be less than {{count}}"
      less_than_or_equal_to: "must be less than or equal to {{count}}"
      odd: "must be odd"
      even: "must be even"

As a result, the error message will read first_name can't be blank.

The Error object is also a part of ActiveModel.

Serialization

ActiveRecord also comes with default serialization for JSON and XML, allowing you to do things like: @person.to_json(:except => :comment).

The main important part of the serialization support is adding general support for specifying the attributes to include across all serializers. That means that you can do @person.to_xml(:except => :comment) as well.

To add serialization support to your own model, you will need to include the serialization module and implement attributes. Check it out:

class Person  
  include ActiveModel::Serialization

  attr_accessor :attributes
  def initialize(attributes)
    @attributes = attributes
  end
end

p = Person.new(:first_name => "Yukihiro", :last_name => "Matsumoto")  
p.to_json #=> %|{"first_name": "Yukihiro", "last_name": "Matsumoto"}|  
p.to_json(:only => :first_name) #=> %|{"first_name": "Yukihiro"}|  

You can also pass in a :methods option to specify methods to call for certain attributes that are determined dynamically.

Here's the Person model with validations and serialization:

class Person  
  include ActiveModel::Validations
  include ActiveModel::Serialization

  validates_presence_of :first_name, :last_name

  attr_accessor :attributes
  def initialize(attributes = {})
    @attributes = attributes
  end

  def read_attribute_for_validation(key)
    @attributes[key]
  end
end  

Others

Those are just two of the modules available in ActiveModel. Some others include:

  • AttributeMethods: Makes it easy to add attributes that are set like table_name :foo
  • Callbacks: ActiveRecord-style lifecycle callbacks.
  • Dirty: Support for dirty tracking
  • Naming: Default implementations of model.model_name, which are used by ActionPack (for instance, when you do render :partial => model
  • Observing: ActiveRecord-style observers
  • StateMachine: A simple state-machine implementation for models
  • Translation: The core translation support

This mostly reflects the first step of ActiveRecord extractions done by Josh Peek for his Google Summer of Code project last summer. Over time, I expect to see more extractions from ActiveRecord and more abstractions built up around ActiveModel.

I also expect to see a community building up around things like adding new validators, translations, serializers and more, especially now that they can be reused not only in ActiveRecord, but in MongoMapper, Cassandra Object, and other ORMs that leverage ActiveModel's built-in modules.