Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Newer
Older
100644 255 lines (246 sloc) 13.191 kB
db045db @dhh Initial
dhh authored
1 module ActiveRecord
4ad6103 @rizwanreza Adds title and basic description where needed.
rizwanreza authored
2 # = Active Record Aggregations
db045db @dhh Initial
dhh authored
3 module Aggregations # :nodoc:
4e50a35 @josh Break up DependencyModule's dual function of providing a "depend_on" …
josh authored
4 extend ActiveSupport::Concern
7c8d2f2 @dhh Removed broken attempt to DRY module ClassMethod #970
dhh authored
5
d496db1 @jeremy Reloading an instance refreshes its aggregations as well as its assoc…
jeremy authored
6 def clear_aggregation_cache #:nodoc:
344a2d5 @tenderlove use a hash for caching aggregations rather than ivars
tenderlove authored
7 @aggregation_cache.clear if persisted?
d496db1 @jeremy Reloading an instance refreshes its aggregations as well as its assoc…
jeremy authored
8 end
9
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
10 # Active Record implements aggregation through a macro-like class method called +composed_of+
11 # for representing attributes as value objects. It expresses relationships like "Account [is]
12 # composed of Money [among other things]" or "Person [is] composed of [an] address". Each call
13 # to the macro adds a description of how the value objects are created from the attributes of
14 # the entity object (when the entity is initialized either as a new object or from finding an
15 # existing object) and how it can be turned back into attributes (when the entity is saved to
b29c23a @neerajdotname ensuring that documentation does not exceed 100 columns
neerajdotname authored
16 # the database).
db045db @dhh Initial
dhh authored
17 #
18 # class Customer < ActiveRecord::Base
19 # composed_of :balance, :class_name => "Money", :mapping => %w(balance amount)
20 # composed_of :address, :mapping => [ %w(address_street street), %w(address_city city) ]
21 # end
22 #
23 # The customer class now has the following methods to manipulate the value objects:
24 # * <tt>Customer#balance, Customer#balance=(money)</tt>
25 # * <tt>Customer#address, Customer#address=(address)</tt>
26 #
27 # These methods will operate with value objects like the ones described below:
28 #
29 # class Money
30 # include Comparable
31 # attr_reader :amount, :currency
2cee51d @rob-at-thewebfellas Added :constructor and :converter options to composed_of and deprecat…
rob-at-thewebfellas authored
32 # EXCHANGE_RATES = { "USD_TO_DKK" => 6 }
33 #
34 # def initialize(amount, currency = "USD")
35 # @amount, @currency = amount, currency
db045db @dhh Initial
dhh authored
36 # end
37 #
38 # def exchange_to(other_currency)
39 # exchanged_amount = (amount * EXCHANGE_RATES["#{currency}_TO_#{other_currency}"]).floor
40 # Money.new(exchanged_amount, other_currency)
41 # end
42 #
43 # def ==(other_money)
44 # amount == other_money.amount && currency == other_money.currency
45 # end
46 #
47 # def <=>(other_money)
48 # if currency == other_money.currency
49 # amount <=> amount
50 # else
51 # amount <=> other_money.exchange_to(currency).amount
52 # end
53 # end
54 # end
55 #
56 # class Address
57 # attr_reader :street, :city
2cee51d @rob-at-thewebfellas Added :constructor and :converter options to composed_of and deprecat…
rob-at-thewebfellas authored
58 # def initialize(street, city)
59 # @street, @city = street, city
db045db @dhh Initial
dhh authored
60 # end
61 #
2cee51d @rob-at-thewebfellas Added :constructor and :converter options to composed_of and deprecat…
rob-at-thewebfellas authored
62 # def close_to?(other_address)
63 # city == other_address.city
db045db @dhh Initial
dhh authored
64 # end
65 #
66 # def ==(other_address)
67 # city == other_address.city && street == other_address.street
68 # end
69 # end
2cee51d @rob-at-thewebfellas Added :constructor and :converter options to composed_of and deprecat…
rob-at-thewebfellas authored
70 #
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
71 # Now it's possible to access attributes from the database through the value objects instead. If
72 # you choose to name the composition the same as the attribute's name, it will be the only way to
73 # access that attribute. That's the case with our +balance+ attribute. You interact with the value
6ac9482 @neerajdotname ensuring that documentation does not exceed 100 columns
neerajdotname authored
74 # objects just like you would any other attribute, though:
db045db @dhh Initial
dhh authored
75 #
76 # customer.balance = Money.new(20) # sets the Money value object and the attribute
77 # customer.balance # => Money value object
a293278 @lifo Merge docrails
lifo authored
78 # customer.balance.exchange_to("DKK") # => Money.new(120, "DKK")
db045db @dhh Initial
dhh authored
79 # customer.balance > Money.new(10) # => true
80 # customer.balance == Money.new(20) # => true
81 # customer.balance < Money.new(5) # => false
82 #
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
83 # Value objects can also be composed of multiple attributes, such as the case of Address. The order
84 # of the mappings will determine the order of the parameters.
db045db @dhh Initial
dhh authored
85 #
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")
2cee51d @rob-at-thewebfellas Added :constructor and :converter options to composed_of and deprecat…
rob-at-thewebfellas authored
90 # customer.address_street # => "May Street"
91 # customer.address_city # => "Chicago"
db045db @dhh Initial
dhh authored
92 #
93 # == Writing value objects
94 #
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
95 # Value objects are immutable and interchangeable objects that represent a given value, such as
96 # a Money object representing $5. Two Money objects both representing $5 should be equal (through
97 # methods such as <tt>==</tt> and <tt><=></tt> from Comparable if ranking makes sense). This is
6ac9482 @neerajdotname ensuring that documentation does not exceed 100 columns
neerajdotname authored
98 # unlike entity objects where equality is determined by identity. An entity class such as Customer can
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
99 # easily have two different objects that both have an address on Hyancintvej. Entity identity is
100 # determined by object or relational unique identifiers (such as primary keys). Normal
6ac9482 @neerajdotname ensuring that documentation does not exceed 100 columns
neerajdotname authored
101 # ActiveRecord::Base classes are entity objects.
db045db @dhh Initial
dhh authored
102 #
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
103 # It's also important to treat the value objects as immutable. Don't allow the Money object to have
104 # its amount changed after creation. Create a new Money object with the new value instead. This
105 # is exemplified by the Money#exchange_to method that returns a new value object instead of changing
106 # its own values. Active Record won't persist value objects that have been changed through means
6ac9482 @neerajdotname ensuring that documentation does not exceed 100 columns
neerajdotname authored
107 # other than the writer method.
db045db @dhh Initial
dhh authored
108 #
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
109 # The immutable requirement is enforced by Active Record by freezing any object assigned as a value
6ac9482 @neerajdotname ensuring that documentation does not exceed 100 columns
neerajdotname authored
110 # object. Attempting to change it afterwards will result in a ActiveSupport::FrozenObjectError.
2cee51d @rob-at-thewebfellas Added :constructor and :converter options to composed_of and deprecat…
rob-at-thewebfellas authored
111 #
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
112 # Read more about value objects on http://c2.com/cgi/wiki?ValueObject and on the dangers of not
6ac9482 @neerajdotname ensuring that documentation does not exceed 100 columns
neerajdotname authored
113 # keeping value objects immutable on http://c2.com/cgi/wiki?ValueObjectsShouldBeImmutable
abdf546 @jeremy Support aggregations in finder conditions. Closes #10572.
jeremy authored
114 #
b518b6c @rob-at-thewebfellas Expanded documentation for new composed_of options
rob-at-thewebfellas authored
115 # == Custom constructors and converters
116 #
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
117 # By default value objects are initialized by calling the <tt>new</tt> constructor of the value
118 # class passing each of the mapped attributes, in the order specified by the <tt>:mapping</tt>
119 # option, as arguments. If the value class doesn't support this convention then +composed_of+ allows
6ac9482 @neerajdotname ensuring that documentation does not exceed 100 columns
neerajdotname authored
120 # a custom constructor to be specified.
b518b6c @rob-at-thewebfellas Expanded documentation for new composed_of options
rob-at-thewebfellas authored
121 #
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
122 # When a new value is assigned to the value object the default assumption is that the new value
123 # is an instance of the value class. Specifying a custom converter allows the new value to be automatically
6ac9482 @neerajdotname ensuring that documentation does not exceed 100 columns
neerajdotname authored
124 # converted to an instance of value class if necessary.
b518b6c @rob-at-thewebfellas Expanded documentation for new composed_of options
rob-at-thewebfellas authored
125 #
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
126 # For example, the NetworkResource model has +network_address+ and +cidr_range+ attributes that
127 # should be aggregated using the NetAddr::CIDR value class (http://netaddr.rubyforge.org). The constructor
128 # for the value class is called +create+ and it expects a CIDR address string as a parameter. New
129 # values can be assigned to the value object using either another NetAddr::CIDR object, a string
130 # or an array. The <tt>:constructor</tt> and <tt>:converter</tt> options can be used to meet
6ac9482 @neerajdotname ensuring that documentation does not exceed 100 columns
neerajdotname authored
131 # these requirements:
b518b6c @rob-at-thewebfellas Expanded documentation for new composed_of options
rob-at-thewebfellas authored
132 #
133 # class NetworkResource < ActiveRecord::Base
134 # composed_of :cidr,
135 # :class_name => 'NetAddr::CIDR',
136 # :mapping => [ %w(network_address network), %w(cidr_range bits) ],
137 # :allow_nil => true,
138 # :constructor => Proc.new { |network_address, cidr_range| NetAddr::CIDR.create("#{network_address}/#{cidr_range}") },
139 # :converter => Proc.new { |value| NetAddr::CIDR.create(value.is_a?(Array) ? value.join('/') : value) }
140 # end
141 #
142 # # This calls the :constructor
143 # network_resource = NetworkResource.new(:network_address => '192.168.0.1', :cidr_range => 24)
144 #
145 # # These assignments will both use the :converter
146 # network_resource.cidr = [ '192.168.2.1', 8 ]
147 # network_resource.cidr = '192.168.0.1/24'
148 #
149 # # This assignment won't use the :converter as the value is already an instance of the value class
150 # network_resource.cidr = NetAddr::CIDR.create('192.168.2.1/8')
151 #
152 # # Saving and then reloading will use the :constructor on reload
153 # network_resource.save
154 # network_resource.reload
155 #
abdf546 @jeremy Support aggregations in finder conditions. Closes #10572.
jeremy authored
156 # == Finding records by a value object
157 #
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
158 # Once a +composed_of+ relationship is specified for a model, records can be loaded from the database
159 # by specifying an instance of the value object in the conditions hash. The following example
6ac9482 @neerajdotname ensuring that documentation does not exceed 100 columns
neerajdotname authored
160 # finds all customers with +balance_amount+ equal to 20 and +balance_currency+ equal to "USD":
abdf546 @jeremy Support aggregations in finder conditions. Closes #10572.
jeremy authored
161 #
32e296b @miloops Use new finders syntax in docs.
miloops authored
162 # Customer.where(:balance => Money.new(20, "USD")).all
abdf546 @jeremy Support aggregations in finder conditions. Closes #10572.
jeremy authored
163 #
db045db @dhh Initial
dhh authored
164 module ClassMethods
63375b9 @jeremy Grammar fix in aggregations rdoc. Closes #5613.
jeremy authored
165 # Adds reader and writer methods for manipulating a value object:
166 # <tt>composed_of :address</tt> adds <tt>address</tt> and <tt>address=(new_address)</tt> methods.
db045db @dhh Initial
dhh authored
167 #
168 # Options are:
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
169 # * <tt>:class_name</tt> - Specifies the class name of the association. Use it only if that name
170 # can't be inferred from the part id. So <tt>composed_of :address</tt> will by default be linked
171 # to the Address class, but if the real class name is CompanyAddress, you'll have to specify it
6ac9482 @neerajdotname ensuring that documentation does not exceed 100 columns
neerajdotname authored
172 # with this option.
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
173 # * <tt>:mapping</tt> - Specifies the mapping of entity attributes to attributes of the value
174 # object. Each mapping is represented as an array where the first item is the name of the
175 # entity attribute and the second item is the name the attribute in the value object. The
176 # order in which mappings are defined determine the order in which attributes are sent to the
6ac9482 @neerajdotname ensuring that documentation does not exceed 100 columns
neerajdotname authored
177 # value class constructor.
b518b6c @rob-at-thewebfellas Expanded documentation for new composed_of options
rob-at-thewebfellas authored
178 # * <tt>:allow_nil</tt> - Specifies that the value object will not be instantiated when all mapped
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
179 # attributes are +nil+. Setting the value object to +nil+ has the effect of writing +nil+ to all
6ac9482 @neerajdotname ensuring that documentation does not exceed 100 columns
neerajdotname authored
180 # mapped attributes.
18a3333 @NZKoz Formatting, grammar and spelling fixes for the associations documenta…
NZKoz authored
181 # This defaults to +false+.
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
182 # * <tt>:constructor</tt> - A symbol specifying the name of the constructor method or a Proc that
183 # is called to initialize the value object. The constructor is passed all of the mapped attributes,
184 # in the order that they are defined in the <tt>:mapping option</tt>, as arguments and uses them
6ac9482 @neerajdotname ensuring that documentation does not exceed 100 columns
neerajdotname authored
185 # to instantiate a <tt>:class_name</tt> object.
b518b6c @rob-at-thewebfellas Expanded documentation for new composed_of options
rob-at-thewebfellas authored
186 # The default is <tt>:new</tt>.
b451de0 @spastorino Deletes trailing whitespaces (over text files only find * -type f -ex…
spastorino authored
187 # * <tt>:converter</tt> - A symbol specifying the name of a class method of <tt>:class_name</tt>
188 # or a Proc that is called when a new value is assigned to the value object. The converter is
189 # passed the single value that is used in the assignment and is only called if the new value is
6ac9482 @neerajdotname ensuring that documentation does not exceed 100 columns
neerajdotname authored
190 # not an instance of <tt>:class_name</tt>.
7b42a1d @jeremy Assigning an instance of a foreign class to a composed_of aggregate c…
jeremy authored
191 #
db045db @dhh Initial
dhh authored
192 # Option examples:
193 # composed_of :temperature, :mapping => %w(reading celsius)
2cee51d @rob-at-thewebfellas Added :constructor and :converter options to composed_of and deprecat…
rob-at-thewebfellas authored
194 # composed_of :balance, :class_name => "Money", :mapping => %w(balance amount), :converter => Proc.new { |balance| balance.to_money }
db045db @dhh Initial
dhh authored
195 # composed_of :address, :mapping => [ %w(address_street street), %w(address_city city) ]
02ba035 @dhh Added better defaults for composed_of, so statements like composed_of…
dhh authored
196 # composed_of :gps_location
59c8c63 @dhh Added :allow_nil option for aggregations (closes #5091) [ian.w.white@…
dhh authored
197 # composed_of :gps_location, :allow_nil => true
2cee51d @rob-at-thewebfellas Added :constructor and :converter options to composed_of and deprecat…
rob-at-thewebfellas authored
198 # composed_of :ip_address,
199 # :class_name => 'IPAddr',
200 # :mapping => %w(ip to_i),
201 # :constructor => Proc.new { |ip| IPAddr.new(ip, Socket::AF_INET) },
202 # :converter => Proc.new { |ip| ip.is_a?(Integer) ? IPAddr.new(ip, Socket::AF_INET) : IPAddr.new(ip.to_s) }
59c8c63 @dhh Added :allow_nil option for aggregations (closes #5091) [ian.w.white@…
dhh authored
203 #
fdb7f84 @miloops Remove deprecated block usage in composed_of.
miloops authored
204 def composed_of(part_id, options = {})
2cee51d @rob-at-thewebfellas Added :constructor and :converter options to composed_of and deprecat…
rob-at-thewebfellas authored
205 options.assert_valid_keys(:class_name, :mapping, :allow_nil, :constructor, :converter)
db045db @dhh Initial
dhh authored
206
207 name = part_id.id2name
2cee51d @rob-at-thewebfellas Added :constructor and :converter options to composed_of and deprecat…
rob-at-thewebfellas authored
208 class_name = options[:class_name] || name.camelize
209 mapping = options[:mapping] || [ name, name ]
7b42a1d @jeremy Assigning an instance of a foreign class to a composed_of aggregate c…
jeremy authored
210 mapping = [ mapping ] unless mapping.first.is_a?(Array)
2cee51d @rob-at-thewebfellas Added :constructor and :converter options to composed_of and deprecat…
rob-at-thewebfellas authored
211 allow_nil = options[:allow_nil] || false
212 constructor = options[:constructor] || :new
fdb7f84 @miloops Remove deprecated block usage in composed_of.
miloops authored
213 converter = options[:converter]
2cee51d @rob-at-thewebfellas Added :constructor and :converter options to composed_of and deprecat…
rob-at-thewebfellas authored
214
215 reader_method(name, class_name, mapping, allow_nil, constructor)
216 writer_method(name, class_name, mapping, allow_nil, converter)
db045db @dhh Initial
dhh authored
217
6abda69 @dhh Added preliminary support for join models [DHH] Added preliminary sup…
dhh authored
218 create_reflection(:composed_of, part_id, options, self)
db045db @dhh Initial
dhh authored
219 end
220
221 private
2cee51d @rob-at-thewebfellas Added :constructor and :converter options to composed_of and deprecat…
rob-at-thewebfellas authored
222 def reader_method(name, class_name, mapping, allow_nil, constructor)
11fe216 @tenderlove remove unnecessary module_eval
tenderlove authored
223 define_method(name) do
224 if @aggregation_cache[name].nil? && (!allow_nil || mapping.any? {|pair| !read_attribute(pair.first).nil? })
225 attrs = mapping.collect {|pair| read_attribute(pair.first)}
226 object = constructor.respond_to?(:call) ?
227 constructor.call(*attrs) :
228 class_name.constantize.send(constructor, *attrs)
229 @aggregation_cache[name] = object
db045db @dhh Initial
dhh authored
230 end
11fe216 @tenderlove remove unnecessary module_eval
tenderlove authored
231 @aggregation_cache[name]
7b42a1d @jeremy Assigning an instance of a foreign class to a composed_of aggregate c…
jeremy authored
232 end
233 end
59c8c63 @dhh Added :allow_nil option for aggregations (closes #5091) [ian.w.white@…
dhh authored
234
2cee51d @rob-at-thewebfellas Added :constructor and :converter options to composed_of and deprecat…
rob-at-thewebfellas authored
235 def writer_method(name, class_name, mapping, allow_nil, converter)
11fe216 @tenderlove remove unnecessary module_eval
tenderlove authored
236 define_method("#{name}=") do |part|
237 if part.nil? && allow_nil
238 mapping.each { |pair| self[pair.first] = nil }
239 @aggregation_cache[name] = nil
240 else
241 unless part.is_a?(class_name.constantize) || converter.nil?
242 part = converter.respond_to?(:call) ?
243 converter.call(part) :
244 class_name.constantize.send(converter, part)
59c8c63 @dhh Added :allow_nil option for aggregations (closes #5091) [ian.w.white@…
dhh authored
245 end
11fe216 @tenderlove remove unnecessary module_eval
tenderlove authored
246
247 mapping.each { |pair| self[pair.first] = part.send(pair.last) }
248 @aggregation_cache[name] = part.freeze
7b42a1d @jeremy Assigning an instance of a foreign class to a composed_of aggregate c…
jeremy authored
249 end
59c8c63 @dhh Added :allow_nil option for aggregations (closes #5091) [ian.w.white@…
dhh authored
250 end
db045db @dhh Initial
dhh authored
251 end
252 end
253 end
254 end
Something went wrong with that request. Please try again.