Alex's Slip-box

These are my org-mode notes in sort of Zettelkasten style

The Utility of ActiveModel

:ID: E7CAC743-E22D-48FC-9922-19081FA8A495

Make use of various ActiveModel modules in your Ruby objects to get ActiveRecord like behavior like type casting, validation, and callbacks. Code that would otherwise be complex, can be simplified by providing a familiar Railsy interface while also saving you some development time.

The form object pattern is one example:

There’s already lot on the internet about form objects in Rails. Rather than just the basic example, I want to document additional use cases organized by the different ActiveModel modules and the specialized behavior they can provide our ruby objects.

I’ve found the form object pattern useful as clean way to handle complex or forms where we’re not working with just a single ActiveRecord object. For example, forms that create or update multiple records (eg, an alternative to accepts_nested_attributes_for) or forms that need to do API calls to other services, etc.

This gist of this is that we can leverage ActiveModel modules to add behavior to ruby classes, giving them a familiar ActiveRecord interface.

# ActiveModel::Model

At a minimum, we’ll want this module. It includes ActiveModel::API which provides among other things:

  1. An initializer method that takes a hash and assigns the key-value pairs to the class’ defined attr_accessor
  2. The API needed for interfacing with Action Pack and Action View
  3. Support for declaring validations

See also http://api.rubyonrails.org/classes/ActiveModel/Model.html

The following, rather stupid example, demonstrates the initializer and validations.

class WidgetForm
  include ActiveModel::Model

  attr_accessor :quantity, :expires_at

  validates :quantity, presence: true

  def submit
    return false if invalid?
    # Do the submitting stuff
  end
end
form = WidgetForm.new(quantity: '3', expires_at: '2023-12-31')
form.quantity # => "3"
form.expires_at # => "2023-12-31"
form = WidgetForm.new(expires_at: '2023-12-31')
form.validate! # => ActiveModel::ValidationError: Validation failed: Quantity can't be blank

This gives us the fundamentals for being able to use this form in a controller (and view), taking in user provided parameters. Although, we’re probably going to encounter issues with this naive implementation – more on this below. For now, however, we can implement a controller. The example here is for a JSON API, but these form objects can be used in views with form_with as one would an ActiveRecord object.

class WidgetsController < ApplicationController
  def create
    form = WidgetForm.new(widget_params)
    if form.submit
      render json: form
    else
      render json: { errors: form.errors.full_messages }
    end
  end

  private

  def widget_params
    params.require(:widget).permit(:quantity, :expires_at)
  end
end

When there are validation errors, the client receives the full validation error messages generated by ActiveModel::Model.

curl --request POST \
     --url http://localhost:3000/widgets/ \
     --header 'Accept: application/json' \
     --header 'Content-Type: application/json' \
     --data '{
        "widget": {
          "expires_at": "2024-01-01",
          "quantity": null
        }
      }'

{"errors":["Quantity can't be blank"]}

Without validation errors, the JSON dump of the form is returned to the client.

curl --request POST \
     --url http://localhost:3000/widgets/ \
     --header 'Accept: application/json' \
     --header 'Content-Type: application/json' \
     --data '{
        "widget": {
          "expires_at": "2024-01-01",
          "quantity": 1
        }
      }'

{"attributes":{},"quantity":1,"expires_at":"2024-01-01","validation_context":null,"errors":{}}

This likely isn’t the JSON structure we’d ultimately want, but again this is the most basic approach we can take. We’ll look at options for customizing the JSON output below. But first, lets look at an alternative way to handle the form’s attributes.

# Validating child objects

It’s not uncommon to have forms to create or update one or more ActiveRecord objects. In this case, we can use a custom validation method and add the child object errors to the form.

class WidgetForm
  include ActiveModel::Model

  attr_accessor :quantity, :expires_at

  validates :quantity, presence: true
  validate :validate_component

  def initialize(attrs)
    super(attrs)
    self.component = Component.new(attrs.slice(:type, :name))
  end

  def submit
    return false if invalid?
  end

  private

  def validate_component
    return if component.valid?

    component.errors.each do |error|
      errors.add(error.attribute, error.message)
    end
  end
end

# ActiveModel::Attributes

This is optional, but quite useful as an alternative to the traditional attr_accessor. It allows one to declare type casting and default values. Say if we have a date string, and we’d like to instead represent this using a Ruby Date object for convenience. There are many built in types casts or you can even define your own custom one.

Here we will demonstrate three types:

class WidgetForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :quantity, :integer
  attribute :expires_at, :date
  attribute :active, :boolean, default: true

  validates :quantity, presence: true
end

In this example the string '3' is cast to Integer and the expires_at date string is cast to a Ruby Date object. active is true by default, and setting it to '0' will cast the value to false.

form = WidgetForm.new(quantity: '3', expires_at: '2023-12-31')
form.quantity # => 3
form.expires_at # => Sun, 31 Dec 2023
form.active # => true

form.active = '0'
form.active # => false

See also http://api.rubyonrails.org/classes/ActiveModel/Attributes/ClassMethods.html

This is NOT the ActiveRecord attributes API but it works in some of the same ways. See also ActiveRecord notes.

# ActiveMode::Type

This goes along with ActiveModel::Attributes, specifically the type casting declarations. There are many built in types which will likely satisfy the vast majority of use cases.

If, however,the built in types aren’t enough, we have peculiar use cases, and we’re feeling particularly bold, then we can define a custom type. Let’s say we have temperature that could be submitted in either Celsius or Fahrenheit and we want to store the values in Kelvin.

To do this, we define a custom type class that itself defines a cast method. This method contains the custom logic for doing the conversion from F or C to K in the decimal data type; hence our class inherits from ActiveModel::Type::Decimal.

class Kelvin < ActiveModel::Type::Decimal
  K_BASE = 273.15.to_d

  def cast(value)
    return if value.blank?

    value.upcase!
    temp_in_kelvin = if value.include?('C')
                      value.delete('C').to_d + K_BASE
                    elsif value.include?('F')
                      ((value.delete('F').to_d - 32) * (Rational(5, 9))) + K_BASE
                    end
    super(temp_in_kelvin)
  end
end

Then we register the custom type in an initializer…

# config/initializers/active_model_types.rb
ActiveModel::Type.register(:kelvin, Kelvin)

…And use it in the same way we would use one of the built in types.

class WidgetForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :temperature, :kelvin
end
form = WidgetForm.new(temperature: '1 C')
form.temperature # => 0.27415e3

form = WidgetForm.new(temperature: '1 f')
form.temperature # => 0.255927777764e3

Doing this does have implications for front ends when displaying the value back to the user in their temperature unit preference. For example, returning the value in scientific notation in K would be quite unexpected when the user submitting the value in either C or F. So we’ll probably need some custom conversion to case the value back to F or C.

class Kelvin < ActiveModel::Type::Decimal
  K_BASE = 273.15.to_d

  def self.to_preferred_unit(value, unit)
    return if value.blank?

    if unit == 'C'
      "#{(value - K_BASE)} C"
    elsif unit == 'F'
      fahrenheit = (((value - K_BASE) * 9) / 5) + 32
      "#{fahrenheit.round(2)} F"
    end
  end
end
form = WidgetForm.new(temperature: '15.3 c')
form.temperature # => 0.28845e3
Kelvin.to_preferred_unit(form.temperature, 'C') # => "15.3 C"

form = WidgetForm.new(temperature: '104.3 f')
form.temperature # => 0.3133166666666666666988e3
Kelvin.to_preferred_unit(form.temperature, 'F') # => "104.3 F"

Now we can display the value back to the user in their preferred unit. For the JSON representation, we’ll need to customize the JSON dump (more on this below)

# ActiveModel::Serialization

In basic example above, our controller action returns the JSON representation of the WidgetForm. It was OK, but not ideal. If we to customize this, we could override the as_json method to return a serializable hash of just the attributes we want to return to the client.

class WidgetForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :name, :string
  attribute :quantity, :integer
  attribute :expires_at, :date
  attribute :active, :boolean, default: true
  attribute :temperature, :kelvin
  attribute :preferred_temperature_unit, :string

  validates :preferred_temperature_unit, inclusion: %w[C F], allow_nil: true

  def initialize(attributes)
    super(attributes)
    set_temperature_unit(attributes['temperature'])
  end

  def as_json(_opts = nil)
    attributes.merge(temperature: Kelvin.to_preferred_unit(temperature, preferred_temperature_unit))
  end

  private

  def set_temperature_unit(temperature)
    return if temperature.blank?

    self.preferred_temperature_unit = temperature.upcase.match(/[FC]/)[0]
  end
end

There a little bit more going on here than just overriding as_json. We’re also defining an initialize method that will call a method to set the ~preferred_temperature_unit attribute (eg, F or C) from the temperature before it is type cast to Kelvin. The preferred_temperature_unit is used to convert the temperature in Kelvin back to Fahrenheit or Celsius. ActiveRecord has a convenience method, read_attribute_before_type_cast, that would be helpful here, but it is not provided by ActiveModel::Attributes. So, we do a little more work.

The attributes method comes from ActiveModel::Attributes and returns a hash of the declared attributes and their values.

Using this, we get a more sensible JSON response:

curl --request POST \
     --url http://localhost:3000/widgets/ \
     --header 'Accept: application/json' \
     --header 'Content-Type: application/json' \
     --data '{
        "widget": {
        "expires_at": "2024-01-01",
        "quantity": 1,
        "temperature": "1 c",
        "name": "da bomb widget 5000"
  }
}'

{"name":"da bomb widget 5000","quantity":1,"expires_at":"2024-01-01","active":true,"temperature":"1.0 C","preferred_temperature_unit":"C"}

Okay that’s nice, so what about ActiveModel::Serialization? We can use include this module to provide some flexibility in how the object is serialized, by providing the serializable_hash method. We can use this in the controller to customize the serialization. For example, we don’t want to send back the preferred_temperature_unit since this is something we compute as part of the type casting to Kelvin, but we do want to return the temperature value in Kelvin. We can do that like this:

class WidgetsController < ApplicationController
  def create
    form = WidgetForm.new(widget_params)
    if form.submit
      render json: form.serializable_hash(
               except: :preferred_temperature_unit, methods: :temperature_in_kelvin
             )
    else
      render json: { errors: form.errors.full_messages }
    end
  end
end

We exclude the preferred_temperature_unit and include a new method temperature_in_kelvin which is an aliased attribute of temperature. To make this work, there are a couple things we need to do in the form. Namely, create the alias and make sure we’re returning the temperature in the preferred unit.

class WidgetForm
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Serialization

  attribute :name, :string
  attribute :quantity, :integer
  attribute :expires_at, :date
  attribute :active, :boolean, default: true
  attribute :temperature, :kelvin
  attribute :preferred_temperature_unit, :string

  validates :quantity, :name, presence: true
  validates :preferred_temperature_unit, inclusion: %w[C F], allow_nil: true

  alias_attribute :temperature_in_kelvin, :temperature

  def initialize(attributes)
    super(attributes)
    set_temperature_unit(attributes['temperature'])
  end

  private

  def read_attribute_for_serialization(attribute)
    if attribute == 'temperature'
      Kelvin.to_preferred_unit(temperature, preferred_temperature_unit)
    else
      super
    end
  end

  def set_temperature_unit(temperature)
    return if temperature.blank?

    self.preferred_temperature_unit = temperature.upcase.match(/[FC]/)[0]
  end
end

See also alias_attribute

The read_attribute_for_serialization is a private method that is called for each attribute name that is included in the serializable_hash. There is very little documentation on this, but it is referenced in the ActiveModel::Serialization docs. See the implementation in the code here.

# ActiveModel::Callback

This module will provide the ability to define callbacks that can be declared just like ActiveRecord callbacks (eg, before_create, after_initialize, etc)

Extending from this module will provide the define_model_callbacks. As an example, lets say we want to do some benchmarking around the submit method.

There are three steps to this after extending from ActiveModel::Callbacks:

  1. Use the define_model_callbacks class macro to declare callbacks for a particular method. By default you will get before_, after_ and around_ callbacks. We can optionally specify :only to create only the callbacks we need.
  2. Call run_callbacks passing the callback name registered using define_model_callbacks as an argument. A block should be passed that contains the actual method implementation.
  3. Declare the callback passing a method name containing the code that should be run as part of the callback. Optionally, a class could be used instead of a method (see docs for more on that). Here, we using a method to run the benchmark and log the results.
class WidgetForm
  include ActiveModel::Model
  include ActiveModel::Attributes
  extend ActiveModel::Callbacks

  define_model_callbacks :submit, only: :around

  around_submit :log_benchmark

  def submit
    run_callbacks :submit do
      return false if invalid?

      # Do the submitting stuff
    end
  end

  private

  def log_benchmark
    benchmark = Benchmark.measure do
      yield
    end
    Rails.logger.info "#{self.class}#submit benchmark results:\n#{benchmark}"
  end
end

# ActiveModel::Validation::Callbacks

Include this module to use before_validation and after_validation callbacks. Lets say we require the presence of a slug but it is something generated automatically from the name.

class WidgetForm
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Validations::Callbacks

  attribute :name, :string
  attribute :slug, :string

  validates :name, :slug, presence: true

  before_validation :set_slug

  private

  def set_slug
    return if name.blank?

    self.slug = name.parameterize
  end
end
form = WidgetForm.new(name: 'da bomb widget 5000')
form.slug # => nil
form.valid? # => true
form.slug # => "da-bomb-widget-5000"

Search Results