Normalize ActiveRecord::Relation interfaces

Yang Liu
2 min readFeb 26, 2019

--

Define ActiveRecord::Relation interface

An “ActiveRecord::Relation interface” (short as Relation interface, RI) is an interface with `ActiveRecord::Relation` return type. Examples are:

class Address
# Address#orders is an RI
has_many :orders
# User.active is an RI
scope :active, -> { where(...) }
# Address#nearby_addresses
def nearby_addresses
where(...).order(...).select(...)
end
end

Normalization of RI

For RI which invoked group, order, select, or any other method with side effects, we should normalize it so their side effect will not leak to the outside.

Here the side effect means it changed the inner state of the Relation so the following chained method will have a different result before the state change.

Why normalization?

An RI, differ from other local code, its result may be used in other places, very often in other files. And at the place where someone invoked this RI, the side effect is nearly impossible to be known by the caller.

Here’s a bug I just fixed recently. Here we have a GasStation model, and a GasStation has many GasPrice. And this latest_prices returns the latest prices for some specific stations.

class GasPrice
def self.latest_prices(stations:)
where(gas_station_id: stations.select(:id)).
order(:gas_station_id, posted_at: :desc).
select("distinct on (gas_station_id) *")
end
end

And then in the usage, we use this to calculate the lowest price among all stations.

lowest = GasPrice.latest_prices(stations).order(:price).first

However, this code may not work because this order(:price) is actually chained after the and some chained order may break the order(:gas_station_id, posted_at: :desc) setting so the select distinct will not work.

Even worse, if you decide to batch process all the prices:

GasPrice.latest_prices(stations).find_each { |p| ... }

This will raise an error:

PG::InvalidColumnReference: ERROR:  SELECT DISTINCT ON expressions must match initial ORDER BY expressions

Because find_each will reset all the order setting.

How to normalize?

The normalization, for now, I can think of, is wrapping the original query withModel.where(id: original_query). This way all the side effects will be constrained inside the subquery and will never leak to the outside.

class GasPrice
def self.latest_prices(stations)
where(id:
where(gas_station_id: stations.select(:id)).
order(:gas_station_id, posted_at: :desc).
select("distinct on (gas_station_id) *")
)
end
end

Then we can safely do any order, reorder, and/or combine with other queries without worrying about the chained methods messed up with the previous settings.

--

--