Kushagra Singh
In our previous post, when working with the Restaurant Management Application, we looked at extracting the Measurement value object that was littered in the model code and modeling the Ingredient quantities around it.
We now have a new problem where range queries with Measurement do not work as expected.
This time we'll build on the same example using the Rails Attributes API.
Along the way, we'll keep looking at the benefits as we make these changes.
And by the end of it we should have an "obvious" feeling API to work with, where passing Measurement ranges to quantity Just Works™.
We've got a new feature request. The restaurant managers want a summary of ingredients that are well stocked, not too little and not too much.
For our example, let's say "stocked well" means having between 5kg and 10kg of an ingredient.
It would be nice if we could express that directly using the Measurement object.
Introducing the Comparable module to Measurement lets us do things like this.
# rails console
013> Measurement.from("5 kg")..Measurement.from("10 kg")
=> #Range:0x000000012e823de8
014> range = Measurement.from("1 kg")..Measurement.from("2 kg")
=> #Range:0x000000012e662298
015> range.cover?(Measurement.from("1.5 kg"))
=> true
016> range.cover?(Measurement.from("1.99 kg"))
=> true
017> range.cover?(Measurement.from("2.99 kg"))
=> false
It would be great if our Ingredient could leverage the range syntax in our Active Record queries. Let's run the query now.
# rails console
003> Ingredient.where(quantity: Measurement.from("5 kg")..Measurement.from("10 kg"))
activesupport (8.1.3.1) lib/active_support/core_ext/object/try.rb:28:in 'Kernel#public_send': undefined method 'value' for an instance of Range (NoMethodError)
public_send(*args, &block)
^^^^^^^^^^^
from activesupport (8.1.3.1) lib/active_support/core_ext/object/try.rb:28:in 'ActiveSupport::Tryable#try!'
from activerecord (8.1.3.1) lib/active_record/relation/predicate_builder.rb:141:in 'block (3 levels) in ActiveRecord::PredicateBuilder#expand_from_hash'
from activerecord (8.1.3.1) lib/active_record/relation/predicate_builder.rb:140:in 'Hash#each'
from activerecord (8.1.3.1) lib/active_record/relation/predicate_builder.rb:140:in 'Enumerable#map'
from activerecord (8.1.3.1) lib/active_record/relation/predicate_builder.rb:140:in 'block (2 levels) in ActiveRecord::PredicateBuilder#expand_from_hash'
from activerecord (8.1.3.1) lib/active_record/relation/predicate_builder.rb:139:in 'Array#map'
from activerecord (8.1.3.1) lib/active_record/relation/predicate_builder.rb:139:in 'block in ActiveRecord::PredicateBuilder#expand_from_hash'
from activerecord (8.1.3.1) lib/active_record/relation/predicate_builder.rb:87:in 'Hash#each'
from activerecord (8.1.3.1) lib/active_record/relation/predicate_builder.rb:87:in 'Enumerable#flat_map'
from activerecord (8.1.3.1) lib/active_record/relation/predicate_builder.rb:87:in 'ActiveRecord::PredicateBuilder#expand_from_hash'
from activerecord (8.1.3.1) lib/active_record/relation/predicate_builder.rb:25:in 'ActiveRecord::PredicateBuilder#build_from_hash'
from activerecord (8.1.3.1) lib/active_record/relation/query_methods.rb:1644:in 'ActiveRecord::QueryMethods#build_where_clause'
from activerecord (8.1.3.1) lib/active_record/relation/query_methods.rb:1044:in 'ActiveRecord::QueryMethods#where!'
from activerecord (8.1.3.1) lib/active_record/relation/query_methods.rb:1039:in 'ActiveRecord::QueryMethods#where'
from activerecord (8.1.3.1) lib/active_record/querying.rb:24:in 'ActiveRecord::Querying#where'
from (playground):3:in '<main>'
... 17 levels...
That's a little unexpected, not the best Developer Experience in my opinion.
We'll try to understand why this happened. The backtrace tells us exactly where to look. We can see that the error occurred somewhere in predicate_builder.rb from activerecord .
Let's read some code, we'll open the source using bundle open activerecord and navigate to the PredicateBuilder#expand_from_hash .
# lib/active_record/relation/predicate_builder.rb
def expand_from_hash(attributes, &block)
# ...
elsif table.aggregated_with?(key) # Handles the 'composed_of' case.
queries = values.map do |object|
mapping.map do |field_attr, aggregate_attr|
self[field_attr, object.try!(aggregate_attr)]
end
end
else
# Handles the default case where we have a simple column and value,
# so something like `where(name: "David")` works.
self[key, value]
end
end
Think of PredicateBuilder#expand_from_hash as the method that takes in queries written in Ruby and translates them into Arel Predicates, it walks through the hash passed in and for each key/value pair decides how to handle them based on their type (this is an oversimplification but works here).
Now when we passed in quantity: Measurement.from("5 kg")..Measurement.from("10 kg") to expand_from_hash, the resolved branch was the one where table.aggregated_with?(key) is true, because quantity is the key passed in. composed_of is an aggregation defined in ActiveRecord::Aggregations::ClassMethods.
Now the reason it blows up is, quantity is defined using the composed_of macro, so it is not being treated as a normal database attribute but instead an aggregate.
In the aggregate branch below, Active Record eventually calls value on the object. Since we passed in a Range of Measurements, it ends up calling value on the Range, which is not a method defined on the Range object.
# lib/active_record/relation/predicate_builder.rb
queries = values.map do |object|
mapping.map do |field_attr, aggregate_attr|
self[field_attr, object.try!(aggregate_attr)]
end
end
How can we circumvent that? What if we:
- made the predicate builder think that quantity is just another column
- made it a special type that Active Record understands.
What are Active Record Types?
First, lets look at Active Record Types. The documentation on them is a little sparse, but you can think of them as a way for the underlying database to understand domain objects in ruby and vice versa.
We can register a new Active Record type, this helps Active Record recognise :measurement type attributes.
# config/initializers/attribute_types.rb
Rails.application.config.to_prepare do
ActiveRecord::Type.register(:measurement, MeasurementType)
end
Let's declare the MeasurementType as well, while we are at it.
# app/types/measurement_type.rb
class MeasurementType < ActiveRecord::Type::Value
end
which allows us to do things like declare a column as a MeasurementType
# app/models/ingredient.rb
class Ingredient < ApplicationRecord
attribute :quantity_value, :measurement
#...
end
Let's try to test our change in the console
# rails console
003> Ingredient.where(quantity_value: Measurement.from("5 kg")..Measurement.from("10 kg"))
> can't quote Measurement (TypeError)
Progress! We've got a new error. Active Record is basically complaining that it does not understand how to serialize, or convert, a Measurement into a SQL-compatible value.
Let's add the capability to our newly added type so that it can serialize a Measurement into something the database can understand.
# app/types/measurement_type.rb
class MeasurementType < ActiveRecord::Type::Value
def serialize(measurement)
if measurement.is_a?(Measurement)
measurement.value
end
end
end
Now let's try the query again, and examine the SQL generated:
# rails console
001> Ingredient.where(quantity_value: Measurement.from("5 kg")..Measurement.from("10 kg"))
Ingredient Load (0.6ms) SELECT "ingredients".* FROM "ingredients" WHERE "ingredients"."quantity_value" >= 5.0 AND "ingredients"."quantity_value" < 10.0 /* loading for pp */ LIMIT 11 /*application='Playground'*/
=>
[#<Ingredient:0x000000012adede80
id: 2,
created_at: "2026-08-18 21:09:31.962079000 +0000",
quantity_unit: "kg",
quantity_value: 0.5e1,
updated_at: "2026-08-18 21:09:31.962079000 +0000">]
Great, that works. We'll also make sure that passing numerical values also continues to work.
# app/types/measurement_type.rb
class MeasurementType < ActiveRecord::Type::Value
def serialize(measurement)
if measurement.is_a?(Measurement)
measurement.value
elsif measurement.is_a?(Numeric)
super
end
end
end
With that change we'll get the following queries to work as well.
# rails console
002> Ingredient.where(quantity_value: 5..10)
Ingredient Load (0.6ms) SELECT "ingredients".* FROM "ingredients" WHERE "ingredients"."quantity_value" >= 5 AND "ingredients"."quantity_value" < 10 /* loading for pp */ LIMIT 11 /*application='Playground'*/
=>
[#<Ingredient:0x000000012cf0c2b0
id: 2,
created_at: "2026-08-18 21:09:31.962079000 +0000",
quantity_unit: "kg",
quantity_value: 0.5e1,
updated_at: "2026-08-18 21:09:31.962079000 +0000">]
Now that we have the type working, lets get the second part of the puzzle solved, which is actually passing a Measurement into quantity.
Alias Attribute
We'll do that by using alias_attribute, it allows us to declare an alias for an attribute(duh), so Active Record can resolve quantity to quantity_value when building the query.
# app/models/ingredient.rb
class Ingredient < ApplicationRecord
attribute :quantity_value, :measurement
alias_attribute :quantity, :quantity_value
quantify :quantity
# ...
end
With that change now, when we try to run:
# rails console
001> Ingredient.where(quantity: Measurement.from("5 kg")..Measurement.from("10 kg"))
Ingredient Load (0.9ms) SELECT "ingredients".* FROM "ingredients" WHERE "ingredients"."quantity_value" BETWEEN 5.0 AND 10.0 /* loading for pp */ LIMIT 11 /*application='Playground'*/
=>
[#<Ingredient:0x000000012ad66db8
id: 2,
created_at: "2026-08-18 21:09:31.962079000 +0000",
quantity_unit: "kg",
quantity_value: 0.5e1,
updated_at: "2026-08-18 21:09:31.962079000 +0000">]
Great, that seems to be working now, let's look into why it works.
Internals of Predicate Builder
We'll revisit the PredicateBuilder#expand_from_hash method we looked at earlier, now we'll look into what changed because of adding the alias that made our queries work.
# lib/active_record/relation/predicate_builder.rb
def expand_from_hash(attributes, &block)
return [Arel.sql("1=0", retryable: true)] if attributes.empty? # Handles {}
attributes.flat_map do |key, value|
if key.is_a?(Array) && key.size == 1
# Handle single-element arrays so something
# like `where([:id] => 1)` works.
end
if key.is_a?(Array)
# Handle multi-element arrays so something like
# `where([:id, :name] => [1, "David"])` works.
# ...
elsif value.is_a?(Hash) && !table.has_column?(key)
# Handle nested hashes so something like
# `where(author: { name: "David" })` works.
# ...
elsif (associated_reflection = table.associated_with(key))
# Handle associations so something like `where(author: author)` works.
# ...
elsif table.aggregated_with?(key)
# Handle aggregates so earlier the composed_of version would
# resolve to this branch, and would blow up.
# ...
else
# Handles the default case where we have a simple column and value,
# so something like `where(name: "David")` works.
self[key, value]
end
end
end
Earlier, without the alias_attribute, the attributes passed into expand_from_hash were { "quantity" => #Range:0x0000000012dba9900 } , which resolves to the table.aggregated_with?(key) branch and errored.
With the alias added the attributes passed in to expand_from_hash are {"quantity_value" => #Range:0x0000000012dba9900 } . quantity_value is a column on the database which resolves to the last self[key, value] case, basically it gets handled by the normal case, and the rest of the logic is taken care of by the MeasurementType that we had declared earlier.
So alias_attribute gets the query onto the underlying column, MeasurementType handles serialization, and composed_of continues to handle reading and writing.
Expanding Quantifiable
In our previous post, we had another attribute that had the same shape: reorder_point_quantity. We'd want our Range queries to work with that too.
That means we'd end up repeating the same setup for every attribute backed by a Measurement.
Instead, let's pull this functionality into our Quantifiable concern. Then, rather than knowing about the implementation details, the model can simply declare that an attribute is quantifiable.
# app/models/concerns/quantifiable.rb
module Quantifiable
extend ActiveSupport::Concern
module ClassMethods
def quantify attribute
value_attribute = :"#{attribute}_value"
unit_attribute = :"#{attribute}_unit"
attribute value_attribute, :measurement
alias_attribute attribute, value_attribute
composed_of attribute,
class_name: "Measurement",
mapping: { value_attribute => :value, unit_attribute => :unit },
converter: :from
end
end
end
And then our Ingredient class goes back to this:
class Ingredient < ApplicationRecord
quantify :quantity
quantify :reorder_point_quantity
# ...
end
Now both attributes get the same Measurement behaviour, including range queries.
A word of caution
The solution does work, but there are a few caveats with the code.
- It depends on an implementation detail of an internal API that can be changed without a deprecation warning.
- It is not explicitly documented anywhere.
- A new reader is definitely going to wonder how this works.
Should you use it in your code
I believe you can, if you have extensive tests around the behaviour, so if ever a Rails upgrade breaks anything, you'll know immediately and can add a fix.
Alternatives
Another way we can achieve this is using a dedicated range value object. It's a bit verbose, but it works.
# rails console
001> Ingredient.where(quantity: MeasurementRange.new(Measurement.from("5 kg")..Measurement.from("10 kg")))
Ingredient Load (0.9ms) SELECT "ingredients".* FROM "ingredients" WHERE "ingredients"."quantity_value" BETWEEN 5.0 AND 10.0 /* loading for pp */ LIMIT 11 /*application='Playground'*/
=>
[#<Ingredient:0x000000012ad66db8
id: 2,
created_at: "2026-08-18 21:09:31.962079000 +0000",
quantity_unit: "kg",
quantity_value: 0.5e1,
updated_at: "2026-08-18 21:09:31.962079000 +0000">]
There’s more to say about this approach, so maybe I’ll write about dedicated range value objects in a future post.
Do we need Measurement?
Our examples never looked at conversions. The simplified version which we've been working with only has kilograms, a production setting will have Saffron measured in mg and Flour in kg.
In our examples, keeping the Measurement object might seem redundant, as most of its responsibilities are fulfilled by the MeasurementType, but there is a distinction to be made when it comes to each object's responsibility.MeasurementType has the responsibility of serializing Measurement into a type that the underlying SQL engine could understand. Measurement, on the other hand, is responsible for the behaviour around quantities: addition, subtraction, conversion and scaling.Measurement is to MeasurementType what Integer is to ActiveRecord::Type::Integer.