Ruby on Rails | Screencasts | Download | Documentation | Weblog | Community | Source

root/trunk/activerecord/lib/active_record/aggregations.rb

Revision 9157, 9.3 kB (checked in by bitsweat, 8 months ago)

Partial updates include only unsaved attributes. Off by default; set YourClass.partial_updates = true to enable.

Line 
1 module ActiveRecord
2   module Aggregations # :nodoc:
3     def self.included(base)
4       base.extend(ClassMethods)
5     end
6
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?
11     end
12
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:
18     #
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) ]
22     #   end
23     #
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>
27     #
28     # These methods will operate with value objects like the ones described below:
29     #
30     #  class Money
31     #    include Comparable
32     #    attr_reader :amount, :currency
33     #    EXCHANGE_RATES = { "USD_TO_DKK" => 6 } 
34     #
35     #    def initialize(amount, currency = "USD")
36     #      @amount, @currency = amount, currency
37     #    end
38     #
39     #    def exchange_to(other_currency)
40     #      exchanged_amount = (amount * EXCHANGE_RATES["#{currency}_TO_#{other_currency}"]).floor
41     #      Money.new(exchanged_amount, other_currency)
42     #    end
43     #
44     #    def ==(other_money)
45     #      amount == other_money.amount && currency == other_money.currency
46     #    end
47     #
48     #    def <=>(other_money)
49     #      if currency == other_money.currency
50     #        amount <=> amount
51     #      else
52     #        amount <=> other_money.exchange_to(currency).amount
53     #      end
54     #    end
55     #  end
56     #
57     #  class Address
58     #    attr_reader :street, :city
59     #    def initialize(street, city)
60     #      @street, @city = street, city
61     #    end
62     #
63     #    def close_to?(other_address)
64     #      city == other_address.city
65     #    end
66     #
67     #    def ==(other_address)
68     #      city == other_address.city && street == other_address.street
69     #    end
70     #  end
71    
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:
75     #
76     #   customer.balance = Money.new(20)     # sets the Money value object and the attribute
77     #   customer.balance                     # => Money value object
78     #   customer.balance.exchanged_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 of the mappings will
84     # determine the order of the parameters. Example:
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")
90     #   customer.address_street # => "May Street"
91     #   customer.address_city   # => "Chicago"
92     #
93     # == Writing value objects
94     #
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 == and <=> 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 <tt>ActiveRecord::Base</tt> classes are entity objects.
100     #
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 <tt>Money#exchanged_to</tt> 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.
105     #
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 <tt>ActiveSupport::FrozenObjectError</tt>.
108     #
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
111     #
112     # == Finding records by a value object
113     #
114     # Once a +composed_of+ relationship is specified for a model, records can be loaded from the database by specifying an instance
115     # of the value object in the conditions hash. The following example finds all customers with +balance_amount+ equal to 20 and
116     # +balance_currency+ equal to "USD":
117     #
118     #   Customer.find(:all, :conditions => {:balance => Money.new(20, "USD")})
119     #
120     module ClassMethods
121       # Adds reader and writer methods for manipulating a value object:
122       # <tt>composed_of :address</tt> adds <tt>address</tt> and <tt>address=(new_address)</tt> methods.
123       #
124       # Options are:
125       # * <tt>:class_name</tt>  - specify the class name of the association. Use it only if that name can't be inferred
126       #   from the part id. So <tt>composed_of :address</tt> will by default be linked to the +Address+ class, but
127       #   if the real class name is +CompanyAddress+, you'll have to specify it with this option.
128       # * <tt>:mapping</tt> - specifies a number of mapping arrays (attribute, parameter) that bind an attribute name
129       #   to a constructor parameter on the value class.
130       # * <tt>:allow_nil</tt> - specifies that the aggregate object will not be instantiated when all mapped
131       #   attributes are +nil+.  Setting the aggregate class to +nil+ has the effect of writing +nil+ to all mapped attributes.
132       #   This defaults to +false+.
133       #
134       # An optional block can be passed to convert the argument that is passed to the writer method into an instance of
135       # <tt>:class_name</tt>. The block will only be called if the argument is not already an instance of <tt>:class_name</tt>.
136       #
137       # Option examples:
138       #   composed_of :temperature, :mapping => %w(reading celsius)
139       #   composed_of(:balance, :class_name => "Money", :mapping => %w(balance amount)) {|balance| balance.to_money }
140       #   composed_of :address, :mapping => [ %w(address_street street), %w(address_city city) ]
141       #   composed_of :gps_location
142       #   composed_of :gps_location, :allow_nil => true
143       #
144       def composed_of(part_id, options = {}, &block)
145         options.assert_valid_keys(:class_name, :mapping, :allow_nil)
146
147         name        = part_id.id2name
148         class_name  = options[:class_name] || name.camelize
149         mapping     = options[:mapping]    || [ name, name ]
150         mapping     = [ mapping ] unless mapping.first.is_a?(Array)
151         allow_nil   = options[:allow_nil]  || false
152
153         reader_method(name, class_name, mapping, allow_nil)
154         writer_method(name, class_name, mapping, allow_nil, block)
155        
156         create_reflection(:composed_of, part_id, options, self)
157       end
158
159       private
160         def reader_method(name, class_name, mapping, allow_nil)
161           module_eval do
162             define_method(name) do |*args|
163               force_reload = args.first || false
164               if (instance_variable_get("@#{name}").nil? || force_reload) && (!allow_nil || mapping.any? {|pair| !read_attribute(pair.first).nil? })
165                 instance_variable_set("@#{name}", class_name.constantize.new(*mapping.collect {|pair| read_attribute(pair.first)}))
166               end
167               instance_variable_get("@#{name}")
168             end
169           end
170
171         end
172
173         def writer_method(name, class_name, mapping, allow_nil, conversion)
174           module_eval do
175             define_method("#{name}=") do |part|
176               if part.nil? && allow_nil
177                 mapping.each { |pair| self[pair.first] = nil }
178                 instance_variable_set("@#{name}", nil)
179               else
180                 part = conversion.call(part) unless part.is_a?(class_name.constantize) || conversion.nil?
181                 mapping.each { |pair| self[pair.first] = part.send(pair.last) }
182                 instance_variable_set("@#{name}", part.freeze)
183               end
184             end
185           end
186         end
187     end
188   end
189 end
Note: See TracBrowser for help on using the browser.