What composed_of is for

(Skip to the problem)

Chances are you’ve needed to serialize a complex value to a database—a mailing address, for example. An address is composed of several strings, but it is taken all together as a single value.

Active Record’s composed_of macro allows you to control how such a value is serialized to your database. Your database could have fields named address_street, address_city, address_state, and address_zip, for example; but composed_of allows ActiveRecord to load them as an instance of Address into an attribute named address. So you can write

household = Household.new
household.address = Address.new(:street => '1600 Pennsylvania Avenue NW', :city => 'Washington', :state => 'DC', :zip => '20500')

rather than

household = Household.new
household.address_street = '1600 Pennsylvania Avenue NW'
household.address_city = 'Washington'
household.address_state = 'DC'
household.address_zip = '20500'

And, of course, now you can extract your address-specific logic into a reusable class. (Hooray!)

One bit of logic that seems natural to add to our Address class is validation. ActiveModel was designed for just this purpose. We can add ActiveRecord-like validations with a single include:

class Address
  include ActiveModel::Validations
  
  validates :street, :presence => true
  validates :city,   :presence => true
  validates :state,  :presence => true
  validates :zip,    :presence => true, :format => {:with => /\A\d{5}(-\d{4})?\Z/}

And then you can validate an Address along with its Household like this:

validates_associated :address

And everything will look shiny until you ran your tests.

Why composed_of is broken

If you run:

household = Household.new
household.address = Address.new(:street => '1600 Pennsylvania Avenue NW', :city => 'Washington', :state => 'DC', :zip => '20500')
household.valid?

you get TypeError: can't modify frozen object because ActiveRecord is freezing your Address as soon as it is assigned. Here’s a test that demonstrates this:

household = Household.new
address = Address.new(:street => '1600 Pennsylvania Avenue NW', :city => 'Washington', :state => 'DC', :zip => '20500')
address.frozen? # returns false
household.address = address
address.frozen? # returns true

valid? throws the exception because it tries to create two new instance variables in the frozen object. One is @validation_context; the other is @errors, which is instantiated by valid? if it doesn’t already exist.

The following isn’t a pretty work-around; but it is better than monkey-patching Rails or installing evil_ruby to unfreeze objects during validation.

The work-around

  1. Override validation_context to disable that feature.

    def validation_context=(value); end
  2. Make sure that @errors is instantiated before the object is assigned to an ActiveRecord attribute.

    def initialize(*args)
      errors
      # other initialization...
    end

Lighthouse ticket 5646 describes this problem.