Active Record Aggregations
This guide covers how Rails supports aggregations in Active Record models.
I have added this documentation to the official Rails Guides as well. See: activerecordaggregations.html Also published here in case the PR is not merged or takes time to be merged.
After reading this guide, you will know:
- What are value objects and how to define them.
- How to use Value Objects with Active Record aggregations.
- How to query and manipulate aggregated attributes.
What is Active Record Aggregations?
In many domains, simple columns like strings and integers are not expressive enough on their own. You may want to introduce a value object to represent a richer concept in your domain.
For example:
- a
Money
class to represent monetary values, - an
Address
class for postal addresses, - a
Dimension
class for measurements, - or even complex numbers.
Active Record Aggregations provides a way to map these value objects
attributes to database columns using
the composed_of
method.
It is used to express is-composed-of
relationships.
For example:
- An
Account
is composed ofMoney
. - A
User
is composed ofAddress
. - A
Product
is composed ofDimension
.
What are Value Objects?
Value objects are immutable and interchangeable objects that represent a single value or concept, so no setter methods are allowed.
This improves clarity, keeps business rules close to the data they describe, and avoids the pitfalls of using
raw primitive data-types throughout your models and business logic.
They are often used to encapsulate related attributes and behavior, such as Money object can represent $100
, and
Address object can represent 123 Main St, Springfield, IL
.
Value Object Comparison
Any two value objects with the same attributes or calculative equal values are considered equal.
For example:
- two
Money
objects with the same amount and currency are considered equal, even if they are different instances. - Also, two money objects with different amounts but equal value when exchanged to the same currency are considered equal.
While defining a value object, you typically override the ==
and <=>
methods to compare the attributes of the objects.
Also, you define a way to convert between different units if applicable.
NOTE: Entity objects like descendants of ApplicationRecord
are not value objects, as they have a distinct identity and
lifecycle. They are uniquely identified by their primary keys or object ids.
Benefits of Using Value Objects
Using value objects in your application can provide several benefits:
Maintainability: Value objects can help to keep your code organized and maintainable by separating concerns and reducing duplication. Instead of juggling raw strings and integers everywhere, you use meaningful types.
Immutability: Value objects are typically immutable, which can help to prevent unintended side effects and make your code more predictable.
Reduced Complexity: Value objects can make your code more expressive and easier to understand by encapsulating related attributes and behavior.
Defining a Value Object
A value object is typically defined as a plain Ruby class that includes the Comparable
module. They are importantly
immutable and do not let you change the value after creation.
Instead, you need to create a new instance with the new value.
The immutable requirement is enforced by Active Record by freezing any object assigned as a value object. Attempting to
change it afterward will result in a RuntimeError
.
class Money
include Comparable
attr_reader :amount, :currency
EXCHANGE_RATES = { "USD_TO_DKK" => 6, "USD_TO_EUR" => 0.85, "EUR_TO_USD" => 1.17 } # define other exchange rates as needed
def initialize(amount, currency = "USD")
@amount, @currency = amount, currency
end
def exchange_to(other_currency)
exchanged_amount = (amount * EXCHANGE_RATES["#{currency}_TO_#{other_currency}"]).floor
Money.new(exchanged_amount, other_currency)
end
def ==(other_money)
amount == other_money.amount && currency == other_money.currency
end
def <=>(other_money)
if currency == other_money.currency
amount <=> other_money.amount
else
amount <=> other_money.exchange_to(currency).amount
end
end
end
Let’s define another kind of value object, an Address:
class Address
attr_reader :street, :city, :state, :zip
def initialize(street, city, state, zip)
@street, @city, @state, @zip = street, city, state, zip
end
def ==(other_address)
street == other_address.street &&
city == other_address.city &&
state == other_address.state &&
zip == other_address.zip
end
def close_to?(other_address)
city == other_address.city && state == other_address.state
end
end
TIP: Read more about value objects on c2.com/cgi/wiki?ValueObject and on the dangers of not keeping value objects immutable on c2.com/cgi/wiki?ValueObjectsShouldBeImmutable.
Using Value Objects with Active Record
Active Record provides the composed_of
macro(class method) to map value objects to database columns.
You can use composed_of
in your Active Record models to define the relationship between the model and the value
object.
The composed_of
method takes the name of the value object and a hash of options to configure the mapping.
Each call to the macro defines how the value objects are constructed from the model attributes when model instances are built or loaded from the database, and how the model attributes are set when a value object is assigned to the model.
Basic Usage
Let’s say you have a Product
model that has price_cents
and price_currency
columns in the database, and you want
to use a Money
value object to represent the price of the product.
You can define the Product
model like this:
class Product < ApplicationRecord
composed_of :price,
class_name: "Money",
mapping: { price_cents: :amount, price_currency: :currency },
# mapping: [%w(price_cents amount), %w(price_currency currency)], # alternative syntax
constructor: Proc.new { |amount, currency| Money.new(amount || 0, currency || "USD") },
converter: Proc.new { |value| value.is_a?(Money) ? value : Money.new(value) }
end
It is recommended to store money values as integers in the smallest currency unit (e.g. cents) to avoid
floating point precision issues so using price_cents
field name instead of price
.
This will map the price
attribute of the Product
model to a Money
object, using the price_cents
and price_currency
columns in the database.
- The
constructor
option: is a Proc that defines how to create a newMoney
object from the model attributes. The default constructor will callMoney.new
with the mapped attributes. - The
converter
option: takes a symbol specifying a class method define in:class_name
class or takes a Proc that defines how to convert a value assigned to theprice
attribute to aMoney
object. Basically it needs you to define a way to convert non-Money
values toMoney
objects. Converter class method or proc is only called if value passed is not already aMoney
object.
Now you can use the price
attribute of the Product
model as a Money
object:
product = Product.new(price: Money.new(1000, "USD"))
product.price.amount # => 1000
product.price.currency # => "USD"
product.price_cents # => 1000
product.price_currency # => "USD"
product.price # => Money value object
# Updating price
product.price = Money.new(2000, "EUR")
product.price.amount # => 2000
product.price.currency # => "EUR"
product.price_cents # => 2000
product.price_currency # => "EUR"
# assigning non-Money value, it will use the converter proc to convert it to Money object
product.price = 3000
product.price.amount # => 3000
product.price.currency # => "USD" (default currency from constructor)
product.price_cents # => 3000
product.price_currency # => "USD"
product.price.exchange_to("EUR") # => Money.new(2550, "EUR")
# comparing prices
product.price > Money.new(1500, "USD") # => true
product.price > Money.new(2700, "EUR") # => false (1 EUR == 1.17 USD)
product.price == Money.new(3000, "USD") # => true
Using with Other Value Objects
You can use composed_of
with any value object, not just Money
.
For example, let’s say you have a User
model that has street
, city
, state
, and zip
columns in the database,
and you want to use an Address
value object to represent the user’s address.
You can define the User
model like this:
class User < ApplicationRecord
composed_of :address,
class_name: "Address",
mapping: [%w(street street), %w(city city), %w(state state), %w(zip zip)],
constructor: Proc.new { |street, city, state, zip| Address.new(street || "", city || "", state || "", zip || "") },
converter: Proc.new { |value| value.is_a?(Address) ? value : Address.new(value) }
end
Now you can use the address
attribute of the User
model as an Address
object:
user = User.new(address: Address.new("123 Main St", "Springfield", "IL", "62701"))
user.address.street # => "123 Main St"
user.address.city # => "Springfield"
user.address.state # => "IL"
user.address.zip # => "62701"
user.street # => "123 Main St"
user.city # => "Springfield"
user.state # => "IL"
user.zip # => "62701"
user.address = Address.new("456 Elm St", "Springfield", "IL", "62702")
user.address.street # => "456 Elm St"
user.address.city # => "Springfield"
user.address.state # => "IL"
user.address.zip # => "62702"
user.street # => "456 Elm St"
user.city # => "Springfield"
user.state # => "IL"
user.zip # => "62702"
Querying Aggregated Attributes
You can make simple queries using the aggregated attributes. When you query using an aggregated attribute, Active Record will translate the query to use the underlying table columns.
Product.where(price: Money.new(2000, "USD"))
# => SELECT "products".* FROM "products" WHERE "products"."price_cents" = 2000 AND "products"."price_currency" = 'USD'
User.where(address: Address.new("456 Elm St", "Springfield", "IL", "62702"))
# => SELECT "users".* FROM "users" WHERE "users"."street" = '456 Elm St' AND "users"."city" = 'Springfield' AND "users"."state" = 'IL' AND "users"."zip" = '62702'
Please see the caveats section below for more details on querying with values in different units.
Usage in Scopes and Validations
You can also use the aggregated attributes in scopes and validations:
class Product < ApplicationRecord
composed_of :price,
class_name: "Money",
mapping: [%w(price_cents amount), %w(price_currency currency)]
validates :price, presence: true
scope :expensive, -> { where(price: Money.new(2000, "USD")) }
end
Since you cannot use aggregated attribute(like price
) directly in database queries,
so you need to use the underlying table columns(like price_cents
) in scopes for complex queries.
scope :expensive, -> { where("price_cents > ?", 1000) }
Other Option Examples
Here are some examples of value objects you might build using Active Record Aggregations in your app:
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: { 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) }
Caveats
Querying for Same Value with Different Value Attributes
Let’s say we have a Duration
value object that represents a time duration in seconds, minutes, or hours.
class Duration
include Comparable
attr_reader :value, :unit
UNITS_IN_SECONDS = { sec: 1, min: 60, hr: 3600 }
def initialize(value, unit = :seconds)
@value, @unit = value, unit
end
def to_seconds
value * UNITS_IN_SECONDS[unit]
end
def ==(other_duration)
to_seconds == other_duration.to_seconds
end
def <=>(other_duration)
to_seconds <=> other_duration.to_seconds
end
end
while comparing two Duration
objects, they are considered equal if they represent same duration in seconds.
Duration.new(60, :sec) == Duration.new(1, :min)
# => true
Duration.new(7200, :sec) == Duration.new(2, :hr)
# => true
Now let’s define a Task
model that has duration_value
and duration_unit
columns in the database, and you want
to use a Duration
value object to represent the duration of the task.
class Task < ApplicationRecord
composed_of :duration, mapping: { duration_value: :value, duration_unit: :unit }
end
When you create two Task
records with different Duration
objects with different units that represent the same duration,
and later make query using one of those Duration
objects, it will not return both records as some of us might expect.
Duration.new(120, :min) == Duration.new(2, :hr)
# => true
Task.create duration: Duration.new(120, :min)
# => #<Task:0x000000011f6337e0 id: 1, duration_value: 120, duration_unit: "min">
Task.create duration: Duration.new(2, :hr)
# => #<Task:0x000000011be96e18 id: 2, duration_value: 2, duration_unit: "hr">
Task.where(duration: Duration.new(2, :hr))
# => SELECT "tasks".* FROM "tasks" WHERE "tasks"."duration_value" = 2 AND "tasks"."duration_unit" = 'hr'
# => [#<Task:0x000000011be95a18 id: 2, duration_value: 2, duration_unit: "hr">]
Workaround
So to make such queries work as expected, you can choose to store the value in a single unit in the database, like how banks maintain accounts in a particular currency only. So whenever new transaction comes in different currency, it is converted to the account currency before storing. This way you can avoid such issues.
In our example, we can store the duration in seconds only and convert it back to the desired unit when reading.
Dirty Tracking Method for Aggregated Attributes
Active Record Aggregations does not define dirty tracking methods for aggregated attributes directly. Instead, it relies on the underlying table attributes to track changes.
For example:
product = Product.find(1)
product.price = Money.new(2000, "EUR")
product.changed? # => true
product.changes_to_save # => {"price_cents"=>[1000, 2000], "price_currency"=>["USD", "EUR"]}
product.price_changed? # => NoMethodError (no direct price_changed?)
product.price.amount_changed? # => NoMethodError
product.price_cents_changed? # => true
Conclusion
Active Record aggregations provide a powerful way to work with value objects in your Rails applications.
By using the composed_of
method, you can easily map value objects to database columns, allowing
you to encapsulate related attributes and behavior in a single object.
This can help to improve the readability and maintainability of your code, as well as making it easier to work with complex data structures.