Using validates_associated with composed_of and ActiveModel::Validations
What composed_of is for
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
-
Override
validation_contextto disable that feature.def validation_context=(value); end -
Make sure that
@errorsis instantiated before the object is assigned to an ActiveRecord attribute.def initialize(*args) errors # other initialization... end
Lighthouse ticket 5646 describes this problem.