Clarify composed_of allows a Hash for mapping

[`composed_of`](https://api.rubyonrails.org/classes/ActiveRecord/Aggregations/ClassMethods.html#method-i-composed_of)
is a feature that is not widely used and its API is somewhat confusing,
especially for beginners. It was even deprecated for a while, but 10
years after its deprecation, it's still here.

The Rails documentation includes these examples including mapping:
```ruby
composed_of :temperature, mapping: %w(reading celsius)
composed_of :balance, class_name: "Money", mapping: %w(balance amount)
composed_of :address, mapping: [ %w(address_street street),
%w(address_city city) ]
```

Hashes are accepted kind-of accidentally for the `mapping` option. Using
a hash, instead of an array or array of arrays makes the documentation
more beginner-friendly.

```ruby
composed_of :temperature, mapping: { reading: :celsius }
composed_of :balance, class_name: "Money", mapping: { balance: :amount }
composed_of :address, mapping: { address_street: :street, address_city:
:city }
```

Before Ruby 1.9, looping through a hash didn't have deterministic order,
and the mapping order is important, as that's the same order Rails uses
when initializing the value object. Since Ruby 1.9, this isn't an issue
anymore, so we can change the documentation to use hashes instead.

This commit changes the documentation for `composed_of`, clarifying that
any key-value format (both a `Hash` and an `Array`) are accepted for the
`mapping` option. It also adds tests to ensure hashes are also accepted.
This commit is contained in:
Neil Carvalho 2022-08-23 15:30:57 -03:00
parent d33b1ae495
commit ccb2393979
3 changed files with 26 additions and 9 deletions

@ -32,8 +32,8 @@ def init_internals
# the database).
#
# class Customer < ActiveRecord::Base
# composed_of :balance, class_name: "Money", mapping: %w(balance amount)
# composed_of :address, mapping: [ %w(address_street street), %w(address_city city) ]
# composed_of :balance, class_name: "Money", mapping: { balance: :amount }
# composed_of :address, mapping: { address_street: :street, address_city: :city }
# end
#
# The customer class now has the following methods to manipulate the value objects:
@ -150,7 +150,7 @@ def init_internals
# class NetworkResource < ActiveRecord::Base
# composed_of :cidr,
# class_name: 'NetAddr::CIDR',
# mapping: [ %w(network_address network), %w(cidr_range bits) ],
# mapping: { network_address: :network, cidr_range: :bits },
# allow_nil: true,
# constructor: Proc.new { |network_address, cidr_range| NetAddr::CIDR.create("#{network_address}/#{cidr_range}") },
# converter: Proc.new { |value| NetAddr::CIDR.create(value.is_a?(Array) ? value.join('/') : value) }
@ -188,10 +188,10 @@ module ClassMethods
# to the Address class, but if the real class name is +CompanyAddress+, you'll have to specify it
# with this option.
# * <tt>:mapping</tt> - Specifies the mapping of entity attributes to attributes of the value
# object. Each mapping is represented as an array where the first item is the name of the
# entity attribute and the second item is the name of the attribute in the value object. The
# object. Each mapping is represented as a key-value pair where the key is the name of the
# entity attribute and the value is the name of the attribute in the value object. The
# order in which mappings are defined determines the order in which attributes are sent to the
# value class constructor.
# value class constructor. The mapping can be written as a hash or as an array of pairs.
# * <tt>:allow_nil</tt> - Specifies that the value object will not be instantiated when all mapped
# attributes are +nil+. Setting the value object to +nil+ has the effect of writing +nil+ to all
# mapped attributes.
@ -208,14 +208,15 @@ module ClassMethods
# can return +nil+ to skip the assignment.
#
# Option examples:
# composed_of :temperature, mapping: %w(reading celsius)
# composed_of :balance, class_name: "Money", mapping: %w(balance amount)
# composed_of :temperature, mapping: { reading: :celsius }
# composed_of :balance, class_name: "Money", mapping: { balance: :amount }
# composed_of :address, mapping: { address_street: :street, address_city: :city }
# composed_of :address, mapping: [ %w(address_street street), %w(address_city city) ]
# composed_of :gps_location
# composed_of :gps_location, allow_nil: true
# composed_of :ip_address,
# class_name: 'IPAddr',
# mapping: %w(ip to_i),
# mapping: { ip: :to_i },
# constructor: Proc.new { |ip| IPAddr.new(ip, Socket::AF_INET) },
# converter: Proc.new { |ip| ip.is_a?(Integer) ? IPAddr.new(ip, Socket::AF_INET) : IPAddr.new(ip.to_s) }
#

@ -150,6 +150,20 @@ def test_assigning_hash_without_custom_converter
customers(:barney).fullname_no_converter = { first: "Barney", last: "Stinson" }
assert_equal({ first: "Barney", last: "Stinson" }.to_s, customers(:barney).name)
end
def test_hash_mapping
assert_equal "Quiet Road", customers(:barney).address_hash_mapping.street
assert_equal "Peaceful Town", customers(:barney).address_hash_mapping.city
assert_equal "Tranquil Land", customers(:barney).address_hash_mapping.country
end
def test_value_object_with_hash_mapping_assignment_changes_model_attributes
customers(:barney).address_hash_mapping = Address.new("Lively Street", customers(:barney).address_city, customers(:barney).address_country)
customers(:barney).save
assert_equal "Lively Street", customers(:barney).address_street
end
end
class OverridingAggregationsTest < ActiveRecord::TestCase

@ -4,6 +4,8 @@ class Customer < ActiveRecord::Base
cattr_accessor :gps_conversion_was_run
composed_of :address, mapping: [ %w(address_street street), %w(address_city city), %w(address_country country) ], allow_nil: true
composed_of :address_hash_mapping, class_name: "Address",
mapping: { address_street: :street, address_city: :city, address_country: :country }, allow_nil: true
composed_of :balance, class_name: "Money", mapping: %i(balance amount)
composed_of :gps_location, allow_nil: true
composed_of :non_blank_gps_location, class_name: "GpsLocation", allow_nil: true, mapping: %w(gps_location gps_location),