Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Newer
Older
100644 262 lines (253 sloc) 13.492 kb
f4d818d Rafael Mendonça França Revert "Removing composed_of from ActiveRecord."
rafaelfranca authored
1 module ActiveRecord
2 # = Active Record Aggregations
3 module Aggregations # :nodoc:
4 extend ActiveSupport::Concern
5
6 def clear_aggregation_cache #:nodoc:
7 @aggregation_cache.clear if persisted?
8 end
9
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
16 # the database).
17 #
18 # class Customer < ActiveRecord::Base
3c58018 Avner Cohen 1.9 hash syntax changes
AvnerCohen authored
19 # composed_of :balance, class_name: "Money", mapping: %w(balance amount)
20 # composed_of :address, mapping: [ %w(address_street street), %w(address_city city) ]
f4d818d Rafael Mendonça França Revert "Removing composed_of from ActiveRecord."
rafaelfranca authored
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
32 # EXCHANGE_RATES = { "USD_TO_DKK" => 6 }
33 #
34 # def initialize(amount, currency = "USD")
35 # @amount, @currency = amount, currency
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 <=> other_money.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
58 # def initialize(street, city)
59 # @street, @city = street, city
60 # end
61 #
62 # def close_to?(other_address)
63 # city == other_address.city
64 # end
65 #
66 # def ==(other_address)
67 # city == other_address.city && street == other_address.street
68 # end
69 # end
70 #
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
74 # objects just like you would with any other attribute:
75 #
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
82 #
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.
85 #
86 # customer.address_street = "Hyancintvej"
87 # customer.address_city = "Copenhagen"
88 # customer.address # => Address.new("Hyancintvej", "Copenhagen")
89 #
90 # customer.address_street = "Vesterbrogade"
91 # customer.address # => Address.new("Hyancintvej", "Copenhagen")
92 # customer.clear_aggregation_cache
93 # customer.address # => Address.new("Vesterbrogade", "Copenhagen")
94 #
95 # customer.address = Address.new("May Street", "Chicago")
96 # customer.address_street # => "May Street"
97 # customer.address_city # => "Chicago"
98 #
99 # == Writing value objects
100 #
101 # Value objects are immutable and interchangeable objects that represent a given value, such as
102 # a Money object representing $5. Two Money objects both representing $5 should be equal (through
103 # methods such as <tt>==</tt> and <tt><=></tt> from Comparable if ranking makes sense). This is
104 # unlike entity objects where equality is determined by identity. An entity class such as Customer can
105 # easily have two different objects that both have an address on Hyancintvej. Entity identity is
106 # determined by object or relational unique identifiers (such as primary keys). Normal
107 # ActiveRecord::Base classes are entity objects.
108 #
109 # It's also important to treat the value objects as immutable. Don't allow the Money object to have
110 # its amount changed after creation. Create a new Money object with the new value instead. The
111 # Money#exchange_to method is an example of this. It returns a new value object instead of changing
112 # its own values. Active Record won't persist value objects that have been changed through means
113 # other than the writer method.
114 #
115 # The immutable requirement is enforced by Active Record by freezing any object assigned as a value
7bc224d Akira Matsuda remove meaningless AS::FrozenObjectError
amatsuda authored
116 # object. Attempting to change it afterwards will result in a RuntimeError.
f4d818d Rafael Mendonça França Revert "Removing composed_of from ActiveRecord."
rafaelfranca authored
117 #
118 # Read more about value objects on http://c2.com/cgi/wiki?ValueObject and on the dangers of not
119 # keeping value objects immutable on http://c2.com/cgi/wiki?ValueObjectsShouldBeImmutable
120 #
121 # == Custom constructors and converters
122 #
123 # By default value objects are initialized by calling the <tt>new</tt> constructor of the value
124 # class passing each of the mapped attributes, in the order specified by the <tt>:mapping</tt>
125 # option, as arguments. If the value class doesn't support this convention then +composed_of+ allows
126 # a custom constructor to be specified.
127 #
128 # When a new value is assigned to the value object, the default assumption is that the new value
129 # is an instance of the value class. Specifying a custom converter allows the new value to be automatically
130 # converted to an instance of value class if necessary.
131 #
132 # For example, the NetworkResource model has +network_address+ and +cidr_range+ attributes that
133 # should be aggregated using the NetAddr::CIDR value class (http://netaddr.rubyforge.org). The constructor
134 # for the value class is called +create+ and it expects a CIDR address string as a parameter. New
135 # values can be assigned to the value object using either another NetAddr::CIDR object, a string
136 # or an array. The <tt>:constructor</tt> and <tt>:converter</tt> options can be used to meet
137 # these requirements:
138 #
139 # class NetworkResource < ActiveRecord::Base
140 # composed_of :cidr,
3c58018 Avner Cohen 1.9 hash syntax changes
AvnerCohen authored
141 # class_name: 'NetAddr::CIDR',
142 # mapping: [ %w(network_address network), %w(cidr_range bits) ],
143 # allow_nil: true,
144 # constructor: Proc.new { |network_address, cidr_range| NetAddr::CIDR.create("#{network_address}/#{cidr_range}") },
145 # converter: Proc.new { |value| NetAddr::CIDR.create(value.is_a?(Array) ? value.join('/') : value) }
f4d818d Rafael Mendonça França Revert "Removing composed_of from ActiveRecord."
rafaelfranca authored
146 # end
147 #
148 # # This calls the :constructor
3c58018 Avner Cohen 1.9 hash syntax changes
AvnerCohen authored
149 # network_resource = NetworkResource.new(network_address: '192.168.0.1', cidr_range: 24)
f4d818d Rafael Mendonça França Revert "Removing composed_of from ActiveRecord."
rafaelfranca authored
150 #
151 # # These assignments will both use the :converter
152 # network_resource.cidr = [ '192.168.2.1', 8 ]
153 # network_resource.cidr = '192.168.0.1/24'
154 #
155 # # This assignment won't use the :converter as the value is already an instance of the value class
156 # network_resource.cidr = NetAddr::CIDR.create('192.168.2.1/8')
157 #
158 # # Saving and then reloading will use the :constructor on reload
159 # network_resource.save
160 # network_resource.reload
161 #
162 # == Finding records by a value object
163 #
164 # Once a +composed_of+ relationship is specified for a model, records can be loaded from the database
165 # by specifying an instance of the value object in the conditions hash. The following example
166 # finds all customers with +balance_amount+ equal to 20 and +balance_currency+ equal to "USD":
167 #
3d1bc89 Akira Matsuda remove meaningless use of Relation#all
amatsuda authored
168 # Customer.where(balance: Money.new(20, "USD"))
f4d818d Rafael Mendonça França Revert "Removing composed_of from ActiveRecord."
rafaelfranca authored
169 #
170 module ClassMethods
171 # Adds reader and writer methods for manipulating a value object:
172 # <tt>composed_of :address</tt> adds <tt>address</tt> and <tt>address=(new_address)</tt> methods.
173 #
174 # Options are:
175 # * <tt>:class_name</tt> - Specifies the class name of the association. Use it only if that name
176 # can't be inferred from the part id. So <tt>composed_of :address</tt> will by default be linked
177 # to the Address class, but if the real class name is CompanyAddress, you'll have to specify it
178 # with this option.
179 # * <tt>:mapping</tt> - Specifies the mapping of entity attributes to attributes of the value
180 # object. Each mapping is represented as an array where the first item is the name of the
181 # entity attribute and the second item is the name of the attribute in the value object. The
182 # order in which mappings are defined determines the order in which attributes are sent to the
183 # value class constructor.
184 # * <tt>:allow_nil</tt> - Specifies that the value object will not be instantiated when all mapped
185 # attributes are +nil+. Setting the value object to +nil+ has the effect of writing +nil+ to all
186 # mapped attributes.
187 # This defaults to +false+.
188 # * <tt>:constructor</tt> - A symbol specifying the name of the constructor method or a Proc that
189 # is called to initialize the value object. The constructor is passed all of the mapped attributes,
190 # in the order that they are defined in the <tt>:mapping option</tt>, as arguments and uses them
191 # to instantiate a <tt>:class_name</tt> object.
192 # The default is <tt>:new</tt>.
193 # * <tt>:converter</tt> - A symbol specifying the name of a class method of <tt>:class_name</tt>
194 # or a Proc that is called when a new value is assigned to the value object. The converter is
195 # passed the single value that is used in the assignment and is only called if the new value is
196 # not an instance of <tt>:class_name</tt>. If <tt>:allow_nil</tt> is set to true, the converter
197 # can return nil to skip the assignment.
198 #
199 # Option examples:
3c58018 Avner Cohen 1.9 hash syntax changes
AvnerCohen authored
200 # composed_of :temperature, mapping: %w(reading celsius)
201 # composed_of :balance, class_name: "Money", mapping: %w(balance amount),
202 # converter: Proc.new { |balance| balance.to_money }
203 # composed_of :address, mapping: [ %w(address_street street), %w(address_city city) ]
f4d818d Rafael Mendonça França Revert "Removing composed_of from ActiveRecord."
rafaelfranca authored
204 # composed_of :gps_location
3c58018 Avner Cohen 1.9 hash syntax changes
AvnerCohen authored
205 # composed_of :gps_location, allow_nil: true
f4d818d Rafael Mendonça França Revert "Removing composed_of from ActiveRecord."
rafaelfranca authored
206 # composed_of :ip_address,
3c58018 Avner Cohen 1.9 hash syntax changes
AvnerCohen authored
207 # class_name: 'IPAddr',
208 # mapping: %w(ip to_i),
209 # constructor: Proc.new { |ip| IPAddr.new(ip, Socket::AF_INET) },
210 # converter: Proc.new { |ip| ip.is_a?(Integer) ? IPAddr.new(ip, Socket::AF_INET) : IPAddr.new(ip.to_s) }
f4d818d Rafael Mendonça França Revert "Removing composed_of from ActiveRecord."
rafaelfranca authored
211 #
212 def composed_of(part_id, options = {})
213 options.assert_valid_keys(:class_name, :mapping, :allow_nil, :constructor, :converter)
214
215 name = part_id.id2name
216 class_name = options[:class_name] || name.camelize
217 mapping = options[:mapping] || [ name, name ]
218 mapping = [ mapping ] unless mapping.first.is_a?(Array)
219 allow_nil = options[:allow_nil] || false
220 constructor = options[:constructor] || :new
221 converter = options[:converter]
222
223 reader_method(name, class_name, mapping, allow_nil, constructor)
224 writer_method(name, class_name, mapping, allow_nil, converter)
225
226 create_reflection(:composed_of, part_id, nil, options, self)
227 end
228
229 private
230 def reader_method(name, class_name, mapping, allow_nil, constructor)
231 define_method(name) do
232 if @aggregation_cache[name].nil? && (!allow_nil || mapping.any? {|pair| !read_attribute(pair.first).nil? })
233 attrs = mapping.collect {|pair| read_attribute(pair.first)}
234 object = constructor.respond_to?(:call) ?
235 constructor.call(*attrs) :
236 class_name.constantize.send(constructor, *attrs)
237 @aggregation_cache[name] = object
238 end
239 @aggregation_cache[name]
240 end
241 end
242
243 def writer_method(name, class_name, mapping, allow_nil, converter)
244 define_method("#{name}=") do |part|
245 klass = class_name.constantize
246 unless part.is_a?(klass) || converter.nil? || part.nil?
247 part = converter.respond_to?(:call) ? converter.call(part) : klass.send(converter, part)
248 end
249
250 if part.nil? && allow_nil
251 mapping.each { |pair| self[pair.first] = nil }
252 @aggregation_cache[name] = nil
253 else
254 mapping.each { |pair| self[pair.first] = part.send(pair.last) }
255 @aggregation_cache[name] = part.freeze
256 end
257 end
258 end
259 end
260 end
261 end
Something went wrong with that request. Please try again.