Ruby on Rails associations can be buggy

Evgeniy Demin
4 min readFeb 1, 2023

--

When dealing with has_one associations in the Ruby on Rails ORM framework ActiveRecord, there is a tiny but essential detail. This missing point brought a severe issue in one of the projects I worked on.

Unfortunately, resolving the issue afterward took much more work than preventing it from happening. Therefore I would like to share this story with you, so you avoid repeating the same mistakes the hard way.

Ruby on Rails official logo

If you know the subject well: feel free to scroll to the end of the article for the solution. Even though you may know it already, I still believe there will be valuable information for you.

A little story behind

One day, a simple change was brought to my attention after pulling the latest changes from a remote repository.

class User < ActiveRecord::Base
# ...
has_many :payments
has_one :recent_payment, -> { order(created_at: :desc) }, class_name: 'Payment'
# ...
end

We see a user may have many payments and a recent one. For some, this code could look legit. And depending on the usage, it works as expected.

user.payments.order(created_at: :desc).first == user.recent_payment
# => true

Even the following would work fine.

users = User.includes(:recent_payment)

users.all? do |user|
user.payments.sort_by { |payment| -payment.created_at } == user.recent_payment
end
# => true

I want to stop on this example for a moment. For the recent_payment eager loading above, the database would receive the SQL query below.

SELECT payments.* FROM payments
ORDER BY created_at DESC

As you can see, it retrieves all the properly ordered payments from the database. Then ActiveRecord takes only the first payment record per every user, assigns it to the association cache, and ignores the rest. Described flow is inefficient as we fetch more information than we need; however, it works.

Unfortunately, things can go south in other examples.

For example, as soon as we do joins, it brings unexpected (for some) results compared to the above. The following statement should be true if users have many existing payments in the database.

User.joins(:recent_payment).count < User.joins(:payments).count
# => false

We expected has_one association to connect from 0 to 1 associated record. However, it counts in all payments, not just recent ones per user.

SELECT COUNT(*) FROM users
JOIN payments ON payments.user_id = users.id
ORDER BY payments.created_at DESC

Now, the result from the SQL query won’t (can’t) be “smartly” processed by the framework.

There are numerous more examples when the behavior could be inconsistent due to this issue. Before diving into the solution, I would like to show one more example.

Trivial case

Looking at the model defined below, can you say whether it has potential issues?

class User < ActiveRecord::Base
has_one :account
end

The answer is: it depends.

Everything will work as expected if the data in the database follows the rule of a maximum of one associated account per user. However, as soon as, for any reason, a user has more than one account, it can blow out badly.

And the reason can be anything. An application user that clicked several times quickly to create an account produced a race condition issue that ended up with multiple accounts. Or third-party service integration that failed in the middle of the process and repeatedly duplicated a few accounts after the restart.

Even has_and_belongs_to_many associations have the same potential issue because the middle table can have multiple duplicate records resulting in duplication.

One-time solution

The solution is straightforward: we should add a unique constraint on the database level to ensure data consistency.

ActiveRecord official documentation even has a note about it.

Depending on the use case, you might also need to create a unique index and/or a foreign key constraint on the supplier column for the accounts table.

I would rather say it’s crucial always to cover it with an appropriate unique index.

In case the association is between two tables only, you can define a unique index as simply as:

class AddUniqueIndexToAccounts < ActiveRecord::Migration
def change
add_index :accounts, :user_id, unique: true
end
end

In case your association is polymorphic, and some may be has_one when others may be has_many, you can have unique conditional indexes per association to ensure data consistency:

class AddUniqueIndexToAccounts < ActiveRecord::Migration
add_index :accounts, :user_id, unique: true, where: '(user_type = "User")'
end

One more thing

Assuming you checked all existing associations that could cause trouble and fixed the needed ones. But regularly doing that can be time taking and tedious.

Gladly, a tool called database_consistency can perform these checks (and even more) for you automatically. Adding it as part of CI is super convenient to ensure best practices and avoid multiple problems.

In our case, running the tool would give the following report.

$ bundle exec database_consistency
MissingUniqueIndexChecker fail User account model should have proper unique index in the database

Please check it out and follow me for more topics about #ruby and #rails.

--

--

Evgeniy Demin
Evgeniy Demin

Written by Evgeniy Demin

Ruby & Golang practitioner. Remote expert. Open-source contributor. Beginner blogger. Join my network to grow your expertise.

No responses yet