Ruby on Rails associations can be buggy
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.
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