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 @rafaelfranca 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 @AvnerCohen 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 @rafaelfranca 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 @amatsuda remove meaningless AS::FrozenObjectError
amatsuda authored
116 # object. Attempting to change it afterwards will result in a RuntimeError.
f4d818d @rafaelfranca 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 @AvnerCohen 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 @rafaelfranca Revert "Removing composed_of from ActiveRecord."
rafaelfranca authored
146 # end
147 #
148 # # This calls the :constructor
3c58018 @AvnerCohen 1.9 hash syntax changes
AvnerCohen authored
149 # network_resource = NetworkResource.new(network_address: '192.168.0.1', cidr_range: 24)
f4d818d @rafaelfranca 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 @amatsuda remove meaningless use of Relation#all
amatsuda authored
168 # Customer.where(balance: Money.new(20, "USD"))
f4d818d @rafaelfranca 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 @AvnerCohen 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 @rafaelfranca Revert "Removing composed_of from ActiveRecord."
rafaelfranca authored
204 # composed_of :gps_location
3c58018 @AvnerCohen 1.9 hash syntax changes
AvnerCohen authored
205 # composed_of :gps_location, allow_nil: true
f4d818d @rafaelfranca Revert "Removing composed_of from ActiveRecord."
rafaelfranca authored
206 # composed_of :ip_address,
3c58018 @AvnerCohen 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 @rafaelfranca 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.