We build Web & Mobile Applications.
UPDATE! Great news - my patch made it into core I updated the original patch with additional documentation and also took the opportunity to tweak some of the existing documentation too, so hopefully the options for
composed_of
are now a little bit clearer. Perhaps nowcomposed_of
can enjoy a little more popularity.
Did you know ActiveRecord includes support for aggregations? If you did, have you ever used them? Despite being part of Rails from the start composed_of
tends to lurk in the shadows while newer features like named_scope
steal the limelight. It’s time to give composed_of
some love again!
Aggregation helps to make your models richer: for example if you’re dealing with money why deal with integer and decimal attributes when you can use something like the Money
class? Thanks to various patches the shortcomings of the original composed_of
implementation have been addressed, but there is still one that for some reason remains to be fixed: aggregate class constructors.
The problem is simple: composed_of
assumes that the aggregate class can be instantiated using a simple new
constructor that accepts arguments in the order defined by the :mapping
option. In many cases this works, but if your aggregate class doesn’t conform then you’re left to manually initialise your aggregations (in an after_initialize
callback for example). Not very elegant.
Here is an example from a recent project I’ve been working on:
class Visitor < ActiveRecord::Base
composed_of :ip_addr,
:class_name => 'IPAddr',
:mapping => %w(ip to_i)
end
Here the composed_of
aggregation maps the ip
attribute (an integer) to the IPAddr
class from the Ruby standard library. Much of the code that uses this attribute wants to treat it as an IP address rather than a raw integer so it makes sense to represent it as one in the model. Unfortunately this won’t work properly as the IPAddr
constructor requires a second parameter to be passed to the constructor as shown below:
IPAddr.new(1024768100)
=> ArgumentError: unsupported address family
IPAddr.new(1024768100, Socket::AF_INET)
=> #<IPAddr: IPv4:61.20.184.100/255.255.255.255>
Sadly previous attempts to allow composed_of
to be more flexible with constructors have failed to make it into core. Personally I don’t think that too few people requesting an enhancement is a good enough reason not to apply it anyway. Perhaps if composed_of
was more flexible in the first place more people would use it!
Hopefully though it’ll be third time lucky as I’ve just submitted my own patch to improve composed_of
. If the core team still don’t feel motivated to apply it then I guess I’ll just release it as a plugin instead. It takes a slightly different approach then the previous patches:
It adds a new :constructor
option that takes a symbol or a proc that will be used to instantiate the aggregate object. It is optional and if not used then the existing behaviour of calling new
with the mapped attributes will be used.
It deprecates the use of a block to provide a method of converting values assigned to the aggregate attribute. The use of a block didn’t feel particularly consistent with the rest of Rails where typically symbols or procs are passed as options (a good example being the :if
and :unless
options for validations). Of course passing a block will still function, so existing code won’t break, but it will raise a deprecation warning. This change leads on to…
It adds a new :converter
option that also takes a symbol or proc that will be used when the aggregate attribute is assigned a value that is not an instance of the aggregate class. This replaces the old block syntax and makes it easier to do things like calling composed_of
from within a with_options
block. If both the :converter
option and a block are passed to composed_of
then the :converter
option will take precedence.
Here’s the IP address example from earlier updated to take advantage of the new options:
class Visitor < ActiveRecord::Base
composed_of :ip_addr,
:class_name => 'IPAddr',
:mapping => %w(ip to_i),
:constructor => Proc.new { |value| IPAddr.new(value, Socket::AF_INET) },
:converter => Proc.new { |value| value.is_a?(Integer) ? IPAddr.new(value, Socket::AF_INET) : IPAddr.new(value.to_s) }
end
Passing a symbol to either the :constructor
or :converter
options provides an easy way to use a class where the constructor is not named new
but still expects arguments in the order defined by the :mapping
option. For example if your aggregate class uses a parse
method for instantiation you could do this:
class MyClass < ActiveRecord::Base
composed_of :aggregate_value,
:class_name => 'AggregateClass',
:mapping => %w(value to_s),
:constructor => :parse,
:converter => :parse
end
I think these simple changes make aggregation with composed_of
much more powerful without breaking existing code. If you’d like to see composed_of
get the love it deserves, take a look at my patch and give it your support.
Here’s another example to more fully illustrate why there is a need for separate constructors and converters. To clarify:
:constructor
is called when instantiating the aggregate object and is therefore passed all of the mapped attributes in the order they are defined in the :mapping
option.
:converter
is called when a value is assigned to the aggregate attribute and therefore passed the single value that is used in the assignment
Here’s an example that hopefully makes this difference more obvious:
class NetworkResource < ActiveRecord::Base
composed_of :cidr,
:class_name => 'NetAddr::CIDR',
:mapping => [ %w(network_address network), %w(cidr_range bits) ],
:allow_nil => true,
:constructor => Proc.new { |network_address, cidr_range| NetAddr::CIDR.create("#{network_address}/#{cidr_range}") },
:converter => Proc.new { |value| NetAddr::CIDR.create(value.is_a?(Array) ? value.join('/') : value) }
end
# Calls the :constructor proc
n = NetworkResource.new(:network_address => '192.168.0.1', :cidr_range => 24)
# Calls the :converter proc
n.cidr = [ '192.168.2.1', 8 ]
n.cidr = '192.168.0.1/24'
# Doesn't call the :converter proc as the class matches the aggregate class
n.cidr = NetAddr::CIDR.create('192.168.2.1/8')
# Save and reload - uses the :constructor proc on reload
n.save
n.reload