root/trunk/activerecord/lib/active_record/aggregations.rb
| Revision 9157, 9.3 kB (checked in by bitsweat, 8 months ago) |
|---|
| 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.