ActiveModel: Model

I started reading ahead into ActiveRecord…whoa…that’s some crazy stuff right there. Mind was blown with the sprawling nature of it…so many avenues and byways of logic, it’s going to take some serious time to get to the bottom of that. In the mean time, let’s jump into ActiveModel in earnest.

Getting Started with ActiveModel::Model

ActiveModel has a lot of things going on too, but it’s easy to see how they build on one another, so let’s start with ActiveModel::Model

class Coffee
include ActiveModel::Model
attr_accessor :roast, :brand
end

coffee = Coffee.new(roast: "French Roast", brand: "Slate")
coffee.brand # => "Slate"
coffee.roast # => "French Roast"

Really easy. But, how does it do this and what other features do we get via this one include? Here is the source for model.rb:

module Model
def self.included(base)
base.class_eval do
extend ActiveModel::Naming
extend ActiveModel::Translation
include ActiveModel::Validations
include ActiveModel::Conversion
end
end

def initialize(params={})
params.each do |attr, value|
self.public_send("#{attr}=", value)
end if params
end

def persisted?
false
end
end

Initialize

Looking at the code above should make it super easy to see how the Coffee.new works: we iterate over the implied hash of attributes and call public_send on the setter method provided via attr_accessor.

Invokes the method identified by symbol, passing it any arguments specified. Unlike send, public_send calls public methods only.

Straightforward…moving on.

ActiveModel::Naming

This module gives our class some methods which are very handy for routing and the naming of database tables, stuff like route_key and singular_route_key, all through the magic of ActiveSupport’s Inflector. Take a peek:

  def initialize(klass, namespace = nil, name = nil)
@name = name || klass.name

raise ArgumentError, "Class name cannot be blank. You need to supply a name argument when anonymous class given" if @name.blank?

@unnamespaced = @name.sub(/^#{namespace.name}::/, '') if namespace
@klass = klass
@singular = _singularize(@name)
@plural = ActiveSupport::Inflector.pluralize(@singular)
@element = ActiveSupport::Inflector.underscore(ActiveSupport::Inflector.demodulize(@name))
@human = ActiveSupport::Inflector.humanize(@element)
@collection = ActiveSupport::Inflector.tableize(@name)
@param_key = (namespace ? _singularize(@unnamespaced) : @singular)
@i18n_key = @name.underscore.to_sym

@route_key = (namespace ? ActiveSupport::Inflector.pluralize(@param_key) : @plural.dup)
@singular_route_key = ActiveSupport::Inflector.singularize(@route_key)
@route_key << "_index" if @plural == @singular
end

So for instance:

Coffee.model_name.route_key # => "coffees"
Coffee.model_name.singular_route_key # => "coffee"
Coffee.model_name.param_key # => "coffee"
Coffee.model_name.collection # => "coffees"
Coffee.model_name.human # => "Coffee"

Helpful.

ActiveModel::Translation

This is a minor interface for Rails’ i18n implementation that basically leverages Naming for the purpose of translating your object’s name into other localities. It has some savviness for scopes and ancestors, but I’m going to skip it.

ActiveModel::Conversion

Very small piece of code that handles some to_param and to_model methods. Skipping.

ActiveModel::Validations

You already know what this is going to provide:

class Coffee
include ActiveModel::Model
attr_accessor :roast, :brand
validate do |coffee|
errors.add(:roast, 'must be french') unless roast =~ /french/i
errors.add(:brand, 'must not be charbucks') if brand =~ /charbucks/i
end
end


coffee = Coffee.new(roast: "French Roast", brand: "Slate")
coffee.brand # => "Slate"
coffee.roast # => "French Roast"
coffee.valid? # => true
coffee.errors.messages # => {}

coffee = Coffee.new(roast: "French Roast", brand: "Charbucks")
coffee.brand # => "Charbucks"
coffee.roast # => "French Roast"
coffee.valid? # => false
coffee.errors.messages # => {:brand=>["must not be charbucks"]}

So how does it work? Well, ActiveModel::Validations stripped down to the basics looks like:

module Validations
extend ActiveSupport::Concern

included do
extend ActiveModel::Callbacks
extend ActiveModel::Translation

extend HelperMethods
include HelperMethods

attr_accessor :validation_context
define_callbacks :validate, scope: :name

class_attribute :_validators
self._validators = Hash.new { |h,k| h[k] = [] }
end

module ClassMethods
def validates_each(*attr_names, &block)
validates_with BlockValidator, _merge_attributes(attr_names), &block
end

def validate(*args, &block)
options = args.extract_options!
if options.key?(:on)
options = options.dup
options[:if] = Array(options[:if])
options[:if].unshift("validation_context == :#{options[:on]}")
end
args << options
set_callback(:validate, *args, &block)
end
end

def errors
@errors ||= Errors.new(self)
end

def valid?(context = nil)
current_context, self.validation_context = validation_context, context
errors.clear
run_validations!
ensure
self.validation_context = current_context
end

protected

def run_validations!
run_callbacks :validate
errors.empty?
end
end

Of course, there is a whole directory of specific validations and additional logic underneath this one, but for now let’s focus on the bones.

When we include Validations, it pulls in ActiveModel::Callbacks and defines a callback for validation. It also creates a nifty _validators hash with a specific constructor block. If you haven’t used an initialization block on your Hashes yet, check it out:

foo = Hash.new { |h,k| h[k] = [] } # => {}
foo[:a] # => []

bar = Hash.new # => {}
bar[:a] # => nil

Ok, so we have a callback added to our model as well as a hash keyed on model attribute with an array of validators. Callbacks are very complicated (and very cool), but we can take a glimpse via knowing how define_callbacks works:

coffee.send('_validate_callbacks') # => [#<ActiveSupport::Callbacks::Callback:0x007fbc75e13f00 @klass=Coffee, @kind=:before, @chain=[...], @options={:if=>[], :unless=>[]}, @raw_filter=#<Proc:[email protected]:5>, @_is_object_filter=false, @filter="_callback_before_1(self)", @compiled_options="true">]

Looks like our model now has a before callback with a Proc being called. I’ll have to do a follow up post on the magic of Callbacks, but for now I’m sure you can guess what code is inside (our validation code).

So when do our validations get run? From the above we can see run_callbacks manually triggered whenever we call valid? but what wasn’t as obvious to me is that run_callbacks :validate is what triggers the callback procs generated via set_callback in validate(*args,&block).

Linearly then:

  • we include ActiveModel::Model which includes ActiveModel::Validations
  • we specify a validates in our model which gets translated into a set_callback with the right trigger conditions (before, after, context)
  • valid? triggers the execution of all scoped callbacks in the right call order (fancy chains were made)
  • an ActiveModel::Errors object is populated which then provides the errors and messages as necessary

For curiousity’s sake, what runs the callbacks? This does!

    def run_callbacks(kind, &block)
cbs = send("_#{kind}_callbacks")
if cbs.empty?
yield if block_given?
else
runner = cbs.compile
e = Filters::Environment.new(self, false, nil, block)
runner.call(e).value
end
end

Whoa, looks cool. Next time, we will dig into it…I know, ActiveSupport again, but it’s just so magical I can’t resist!

Have fun reading the source!

Published under programming