Submodel
Submodel maps ActiveRecord columns to ActiveModel models, so that hstore or serialized hash columns can share validations and be augmented with some methods. This can greatly help cleanup your business logic.
Usage
# Gemfile
gem 'submodel'
Create a submodel with ActiveModel::Model
. Here’s an example Address
model using Carmen to provide country and state validations.
# app/submodels/address.rb
class Address
include ActiveModel::Model
COUNTRY_CODES = Carmen::Country.all.map(&:code)
CA_STATE_CODES = Carmen::Country.coded('CA').subregions.map(&:code)
US_STATE_CODES = Carmen::Country.coded('US').subregions.map(&:code)
attr_accessor :street_1, :street_2, :city, :state, :country, :postal_code
validates_inclusion_of :country, in: COUNTRY_CODES
validates_inclusion_of :state, in: CA_STATE_CODES, if: :canada?
validates_inclusion_of :state, in: US_STATE_CODES, if: :united_states?
def canada?
country == 'CA'
end
def united_states?
country == 'US'
end
end
Use the submodel
method to map your ActiveRecord columns to the submodel.
# app/models/order.rb
class Order < ActiveRecord::Base
submodel :billing_address, Address
end
Then, accessing #billing_address
will return an instance created with Address.new
. Similarly, passing a hash to #billing_address=
will create a new instance with the hash as argument.
order = Order.new
order.attributes # => { "id" => nil, "billing_address" => nil }
order.billing_address # => #<Address>
order.billing_address.blank? # => true
order.billing_address.street_1 = '123 Fake Street'
order.billing_address # => #<Address street_1="123 Fake Street">
order.billing_address.blank? # => false
order.billing_address = { country: 'CA', state: 'QC' }
order.billing_address # => #<Address state="QC" country="CA">
Note: While the getter creates an instance on demand, blank submodels are persisted as NULL
.
Comparison
When using ==
, your submodel columns will be compared based on the stringified hash of their instance variables. Blank variables are ignored.
order = Order.new
order.billing_address # => #<Address>
order.billing_address == Address.new # => true
order.billing_address == {} # => true
order.billing_address == { street_1: '', street_2: ' ' } # => true
order.billing_address == { street_1: 'foo', street_2: 'bar' } # => false
order.billing_address.country = 'CA'
order.billing_address.state = 'QC'
order.billing_address == { 'country' => 'CA', :state => 'QC' } # => true
order.billing_address == Address.new(country: 'CA') # => false
Extending submodels per-column
You can pass the submodel
method a block to be executed at the class level. For instance, this adds an (unfortunate) validation to shipping_address
, leaving billing_address
as is.
class Order < ActiveRecord::Base
submodel :billing_address, Address
submodel :shipping_address, Address do
validates :country, inclusion: { in: %w[US CA] }
end
end
This gem seems overkill.
You might think “Why not just override the getter and setter?” In my experience, getting this right is always more complex. If you want proper behavior (validation, comparison, FormBuilder support, persistence) you basically have to repeat this code for every column.
© 2014 Rafaël Blais Masson. Submodel is released under the MIT license.