2 module Aggregations
# :nodoc:
3 def self.included(base
)
4 base
.extend(ClassMethods
)
7 def clear_aggregation_cache
#:nodoc:
8 self.class.reflect_on_all_aggregations
.to_a
.each
do |assoc
|
9 instance_variable_set
"@#{assoc.name}", nil
10 end unless self.new_record
?
13 # Active Record implements aggregation through a macro-like class method called +composed_of+ for representing attributes
14 # as value objects. It expresses relationships like "Account [is] composed of Money [among other things]" or "Person [is]
15 # composed of [an] address". Each call to the macro adds a description of how the value objects are created from the
16 # attributes of the entity object (when the entity is initialized either as a new object or from finding an existing object)
17 # and how it can be turned back into attributes (when the entity is saved to the database). Example:
19 # class Customer < ActiveRecord::Base
20 # composed_of :balance, :class_name => "Money", :mapping => %w(balance amount)
21 # composed_of :address, :mapping => [ %w(address_street street), %w(address_city city) ]
24 # The customer class now has the following methods to manipulate the value objects:
25 # * <tt>Customer#balance, Customer#balance=(money)</tt>
26 # * <tt>Customer#address, Customer#address=(address)</tt>
28 # These methods will operate with value objects like the ones described below:
32 # attr_reader :amount, :currency
33 # EXCHANGE_RATES = { "USD_TO_DKK" => 6 }
35 # def initialize(amount, currency = "USD")
36 # @amount, @currency = amount, currency
39 # def exchange_to(other_currency)
40 # exchanged_amount = (amount * EXCHANGE_RATES["#{currency}_TO_#{other_currency}"]).floor
41 # Money.new(exchanged_amount, other_currency)
45 # amount == other_money.amount && currency == other_money.currency
48 # def <=>(other_money)
49 # if currency == other_money.currency
52 # amount <=> other_money.exchange_to(currency).amount
58 # attr_reader :street, :city
59 # def initialize(street, city)
60 # @street, @city = street, city
63 # def close_to?(other_address)
64 # city == other_address.city
67 # def ==(other_address)
68 # city == other_address.city && street == other_address.street
72 # Now it's possible to access attributes from the database through the value objects instead. If you choose to name the
73 # composition the same as the attribute's name, it will be the only way to access that attribute. That's the case with our
74 # +balance+ attribute. You interact with the value objects just like you would any other attribute, though:
76 # customer.balance = Money.new(20) # sets the Money value object and the attribute
77 # customer.balance # => Money value object
78 # customer.balance.exchange_to("DKK") # => Money.new(120, "DKK")
79 # customer.balance > Money.new(10) # => true
80 # customer.balance == Money.new(20) # => true
81 # customer.balance < Money.new(5) # => false
83 # Value objects can also be composed of multiple attributes, such as the case of Address. The order of the mappings will
84 # determine the order of the parameters. Example:
86 # customer.address_street = "Hyancintvej"
87 # customer.address_city = "Copenhagen"
88 # customer.address # => Address.new("Hyancintvej", "Copenhagen")
89 # customer.address = Address.new("May Street", "Chicago")
90 # customer.address_street # => "May Street"
91 # customer.address_city # => "Chicago"
93 # == Writing value objects
95 # Value objects are immutable and interchangeable objects that represent a given value, such as a Money object representing
96 # $5. Two Money objects both representing $5 should be equal (through methods such as <tt>==</tt> and <tt><=></tt> from Comparable if ranking
97 # makes sense). This is unlike entity objects where equality is determined by identity. An entity class such as Customer can
98 # easily have two different objects that both have an address on Hyancintvej. Entity identity is determined by object or
99 # relational unique identifiers (such as primary keys). Normal ActiveRecord::Base classes are entity objects.
101 # It's also important to treat the value objects as immutable. Don't allow the Money object to have its amount changed after
102 # creation. Create a new Money object with the new value instead. This is exemplified by the Money#exchange_to method that
103 # returns a new value object instead of changing its own values. Active Record won't persist value objects that have been
104 # changed through means other than the writer method.
106 # The immutable requirement is enforced by Active Record by freezing any object assigned as a value object. Attempting to
107 # change it afterwards will result in a ActiveSupport::FrozenObjectError.
109 # Read more about value objects on http://c2.com/cgi/wiki?ValueObject and on the dangers of not keeping value objects
110 # immutable on http://c2.com/cgi/wiki?ValueObjectsShouldBeImmutable
112 # == Custom constructors and converters
114 # By default value objects are initialized by calling the <tt>new</tt> constructor of the value class passing each of the
115 # mapped attributes, in the order specified by the <tt>:mapping</tt> option, as arguments. If the value class doesn't support
116 # this convention then +composed_of+ allows a custom constructor to be specified.
118 # When a new value is assigned to the value object the default assumption is that the new value is an instance of the value
119 # class. Specifying a custom converter allows the new value to be automatically converted to an instance of value class if
122 # For example, the NetworkResource model has +network_address+ and +cidr_range+ attributes that should be aggregated using the
123 # NetAddr::CIDR value class (http://netaddr.rubyforge.org). The constructor for the value class is called +create+ and it
124 # expects a CIDR address string as a parameter. New values can be assigned to the value object using either another
125 # NetAddr::CIDR object, a string or an array. The <tt>:constructor</tt> and <tt>:converter</tt> options can be used to
126 # meet these requirements:
128 # class NetworkResource < ActiveRecord::Base
130 # :class_name => 'NetAddr::CIDR',
131 # :mapping => [ %w(network_address network), %w(cidr_range bits) ],
132 # :allow_nil => true,
133 # :constructor => Proc.new { |network_address, cidr_range| NetAddr::CIDR.create("#{network_address}/#{cidr_range}") },
134 # :converter => Proc.new { |value| NetAddr::CIDR.create(value.is_a?(Array) ? value.join('/') : value) }
137 # # This calls the :constructor
138 # network_resource = NetworkResource.new(:network_address => '192.168.0.1', :cidr_range => 24)
140 # # These assignments will both use the :converter
141 # network_resource.cidr = [ '192.168.2.1', 8 ]
142 # network_resource.cidr = '192.168.0.1/24'
144 # # This assignment won't use the :converter as the value is already an instance of the value class
145 # network_resource.cidr = NetAddr::CIDR.create('192.168.2.1/8')
147 # # Saving and then reloading will use the :constructor on reload
148 # network_resource.save
149 # network_resource.reload
151 # == Finding records by a value object
153 # Once a +composed_of+ relationship is specified for a model, records can be loaded from the database by specifying an instance
154 # of the value object in the conditions hash. The following example finds all customers with +balance_amount+ equal to 20 and
155 # +balance_currency+ equal to "USD":
157 # Customer.find(:all, :conditions => {:balance => Money.new(20, "USD")})
160 # Adds reader and writer methods for manipulating a value object:
161 # <tt>composed_of :address</tt> adds <tt>address</tt> and <tt>address=(new_address)</tt> methods.
164 # * <tt>:class_name</tt> - Specifies the class name of the association. Use it only if that name can't be inferred
165 # from the part id. So <tt>composed_of :address</tt> will by default be linked to the Address class, but
166 # if the real class name is CompanyAddress, you'll have to specify it with this option.
167 # * <tt>:mapping</tt> - Specifies the mapping of entity attributes to attributes of the value object. Each mapping
168 # is represented as an array where the first item is the name of the entity attribute and the second item is the
169 # name the attribute in the value object. The order in which mappings are defined determine the order in which
170 # attributes are sent to the value class constructor.
171 # * <tt>:allow_nil</tt> - Specifies that the value object will not be instantiated when all mapped
172 # attributes are +nil+. Setting the value object to +nil+ has the effect of writing +nil+ to all mapped attributes.
173 # This defaults to +false+.
174 # * <tt>:constructor</tt> - A symbol specifying the name of the constructor method or a Proc that is called to
175 # initialize the value object. The constructor is passed all of the mapped attributes, in the order that they
176 # are defined in the <tt>:mapping option</tt>, as arguments and uses them to instantiate a <tt>:class_name</tt> object.
177 # The default is <tt>:new</tt>.
178 # * <tt>:converter</tt> - A symbol specifying the name of a class method of <tt>:class_name</tt> or a Proc that is
179 # called when a new value is assigned to the value object. The converter is passed the single value that is used
180 # in the assignment and is only called if the new value is not an instance of <tt>:class_name</tt>.
183 # composed_of :temperature, :mapping => %w(reading celsius)
184 # composed_of :balance, :class_name => "Money", :mapping => %w(balance amount), :converter => Proc.new { |balance| balance.to_money }
185 # composed_of :address, :mapping => [ %w(address_street street), %w(address_city city) ]
186 # composed_of :gps_location
187 # composed_of :gps_location, :allow_nil => true
188 # composed_of :ip_address,
189 # :class_name => 'IPAddr',
190 # :mapping => %w(ip to_i),
191 # :constructor => Proc.new { |ip| IPAddr.new(ip, Socket::AF_INET) },
192 # :converter => Proc.new { |ip| ip.is_a?(Integer) ? IPAddr.new(ip, Socket::AF_INET) : IPAddr.new(ip.to_s) }
194 def composed_of(part_id
, options
= {}, &block
)
195 options
.assert_valid_keys(:class_name, :mapping, :allow_nil, :constructor, :converter)
197 name
= part_id
.id2name
198 class_name
= options
[:class_name] || name
.camelize
199 mapping
= options
[:mapping] || [ name
, name
]
200 mapping
= [ mapping
] unless mapping
.first
.is_a
?(Array
)
201 allow_nil
= options
[:allow_nil] || false
202 constructor
= options
[:constructor] || :new
203 converter
= options
[:converter] || block
205 ActiveSupport
::Deprecation.warn('The conversion block has been deprecated, use the :converter option instead.', caller
) if block_given
?
207 reader_method(name
, class_name
, mapping
, allow_nil
, constructor
)
208 writer_method(name
, class_name
, mapping
, allow_nil
, converter
)
210 create_reflection(:composed_of, part_id
, options
, self)
214 def reader_method(name
, class_name
, mapping
, allow_nil
, constructor
)
216 define_method(name
) do |*args
|
217 force_reload
= args
.first
|| false
218 if (instance_variable_get("@#{name}").nil? || force_reload
) && (!allow_nil
|| mapping
.any
? {|pair
| !read_attribute(pair
.first
).nil? })
219 attrs
= mapping
.collect
{|pair
| read_attribute(pair
.first
)}
220 object
= case constructor
222 class_name
.constantize
.send(constructor
, *attrs
)
224 constructor
.call(*attrs
)
226 raise ArgumentError
, 'Constructor must be a symbol denoting the constructor method to call or a Proc to be invoked.'
228 instance_variable_set("@#{name}", object
)
230 instance_variable_get("@#{name}")
236 def writer_method(name
, class_name
, mapping
, allow_nil
, converter
)
238 define_method("#{name}=") do |part
|
239 if part
.nil? && allow_nil
240 mapping
.each
{ |pair
| self[pair
.first
] = nil }
241 instance_variable_set("@#{name}", nil)
243 unless part
.is_a
?(class_name
.constantize
) || converter
.nil?
244 part
= case converter
246 class_name
.constantize
.send(converter
, part
)
250 raise ArgumentError
, 'Converter must be a symbol denoting the converter method to call or a Proc to be invoked.'
253 mapping
.each
{ |pair
| self[pair
.first
] = part
.send(pair
.last
) }
254 instance_variable_set("@#{name}", part
.freeze
)