Index: activerecord/test/fixtures/customer.rb =================================================================== --- activerecord/test/fixtures/customer.rb (revision 5212) +++ activerecord/test/fixtures/customer.rb (working copy) @@ -1,6 +1,8 @@ +require 'money' + class Customer < ActiveRecord::Base composed_of :address, :mapping => [ %w(address_street street), %w(address_city city), %w(address_country country) ], :allow_nil => true - composed_of :balance, :class_name => "Money", :mapping => %w(balance amount) + composed_of(:balance, :class_name => "Money", :mapping => %w(balance amount)) { |balance| balance.to_money } composed_of :gps_location, :allow_nil => true end Index: activerecord/test/aggregations_test.rb =================================================================== --- activerecord/test/aggregations_test.rb (revision 5212) +++ activerecord/test/aggregations_test.rb (working copy) @@ -92,4 +92,23 @@ def test_nil_raises_error_when_allow_nil_is_false assert_raises(NoMethodError) { customers(:david).balance = nil } end + + def test_allow_nil_address_set_to_nil_returns_nil + assert_not_nil customers(:zaphod).address + customers(:zaphod).address = nil + assert_nil customers(:zaphod).address + end + + def test_conversion + assert_nothing_raised { customers(:david).balance = "10.00" } + assert_equal Money.new(10), customers(:david).balance + end + + def test_allow_nil_address_loaded_when_only_some_attributes_are_nil + customers(:zaphod).address_street = nil + customers(:zaphod).save + customers(:zaphod).reload + assert customers(:zaphod).address.is_a?(Address) + assert customers(:zaphod).address.street.nil? + end end Index: activerecord/lib/active_record/aggregations.rb =================================================================== --- activerecord/lib/active_record/aggregations.rb (revision 5212) +++ activerecord/lib/active_record/aggregations.rb (working copy) @@ -121,69 +121,58 @@ # * :allow_nil - specifies that the aggregate object will not be instantiated when all mapped # attributes are nil. Setting the aggregate class to nil has the effect of writing nil to all mapped attributes. # This defaults to false. + # + # An optional block can be passed to convert the argument that is passed to the writer method into an instance of + # :class_name. The block will only be called if the arguement is not already an instance of :class_name. # # Option examples: # composed_of :temperature, :mapping => %w(reading celsius) - # composed_of :balance, :class_name => "Money", :mapping => %w(balance amount) + # composed_of(:balance, :class_name => "Money", :mapping => %w(balance amount)) {|balance| balance.to_money } # composed_of :address, :mapping => [ %w(address_street street), %w(address_city city) ] # composed_of :gps_location # composed_of :gps_location, :allow_nil => true # - def composed_of(part_id, options = {}) + def composed_of(part_id, options = {}, &block) options.assert_valid_keys(:class_name, :mapping, :allow_nil) name = part_id.id2name class_name = options[:class_name] || name.camelize mapping = options[:mapping] || [ name, name ] + mapping = [ mapping ] unless mapping.first.is_a?(Array) allow_nil = options[:allow_nil] || false reader_method(name, class_name, mapping, allow_nil) - writer_method(name, class_name, mapping, allow_nil) + writer_method(name, class_name, mapping, allow_nil, block) create_reflection(:composed_of, part_id, options, self) end private def reader_method(name, class_name, mapping, allow_nil) - mapping = (Array === mapping.first ? mapping : [ mapping ]) - - allow_nil_condition = if allow_nil - mapping.collect { |pair| "!read_attribute(\"#{pair.first}\").nil?"}.join(" && ") - else - "true" - end - - module_eval <<-end_eval - def #{name}(force_reload = false) - if (@#{name}.nil? || force_reload) && #{allow_nil_condition} - @#{name} = #{class_name}.new(#{mapping.collect { |pair| "read_attribute(\"#{pair.first}\")"}.join(", ")}) + module_eval do + define_method(name) do |*args| + force_reload = args.first || false + if (instance_variable_get("@#{name}").nil? || force_reload) && (!allow_nil || mapping.any? {|pair| !read_attribute(pair.first).nil? }) + instance_variable_set("@#{name}", class_name.constantize.new(*mapping.collect {|pair| read_attribute(pair.first)})) end - return @#{name} + return instance_variable_get("@#{name}") end - end_eval - end - - def writer_method(name, class_name, mapping, allow_nil) - mapping = (Array === mapping.first ? mapping : [ mapping ]) + end - if allow_nil - module_eval <<-end_eval - def #{name}=(part) - if part.nil? - #{mapping.collect { |pair| "@attributes[\"#{pair.first}\"] = nil" }.join("\n")} - else - @#{name} = part.freeze - #{mapping.collect { |pair| "@attributes[\"#{pair.first}\"] = part.#{pair.last}" }.join("\n")} - end + end + + def writer_method(name, class_name, mapping, allow_nil, conversion) + module_eval do + define_method("#{name}=") do |part| + if part.nil? && allow_nil + mapping.each { |pair| @attributes[pair.first] = nil } + instance_variable_set("@#{name}", nil) + else + part = conversion.call(part) unless part.is_a?(class_name.constantize) || conversion.nil? + mapping.each { |pair| @attributes[pair.first] = part.send(pair.last) } + instance_variable_set("@#{name}", part.freeze) end - end_eval - else - module_eval <<-end_eval - def #{name}=(part) - @#{name} = part.freeze - #{mapping.collect{ |pair| "@attributes[\"#{pair.first}\"] = part.#{pair.last}" }.join("\n")} - end - end_eval + end end end end