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 read_attribute_for_validation
to get the attribute, but by default, it aliases that method to send
, which supports the standard Ruby attribute system of attr_accessor
.
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
def validates_presence_of(*attr_names)
validates_with PresenceValidator, _merge_attributes(attr_names)
end
You can see that validates_presence_of
is using the more primitive validates_with
, passing it the validator class, merging in {:attributes => attribute_names}
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 add_on_blank
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 liketable_name :foo
Callbacks
: ActiveRecord-style lifecycle callbacks.Dirty
: Support for dirty trackingNaming
: Default implementations ofmodel.model_name
, which are used by ActionPack (for instance, when you dorender :partial => model
Observing
: ActiveRecord-style observersStateMachine
: A simple state-machine implementation for modelsTranslation
: 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.