Are you absolutely sure your `has_one` association really has one association?

Steve Polito
Edited by thoughtbot

The Rails has_one API has an unexpected limitation: It does not prevent multiple records from being associated to the parent record.

Take this simple example straight from the Rails Guides. We have a supplier that has_one account. Seems simple enough, but let’s take a closer look.

class Supplier < ApplicationRecord
  has_one :account
end

class Account < ApplicationRecord
  belongs_to :supplier
end

class CreateAccounts < ActiveRecord::Migration[7.0]
  def change
    create_table :accounts do |t|
      t.string :name
      t.belongs_to :supplier, null: false, foreign_key: true

      t.timestamps
    end
  end
end

The API provides association methods including create_other(attributes={}). This allows us to create an account off of a supplier. If we do this more than once, we’ll raise an error.

supplier = Supplier.create!
supplier.create_account(name: "first")

Account.count
# => 1

supplier.create_account(name: "second")
# => Failed to remove the existing associated account. The record failed to save after its foreign key was set to nil. (ActiveRecord::RecordNotSaved)

At first glance, you might think that the ActiveRecord::RecordNotSaved error means that our has_one association is working as expected, and that it prevented another account record from being created. Unfortunately, what’s really happening is that we couldn’t nullify the supplier_id on the existing account, but we were still able to create a new account that is associated with the supplier. This leaves us with an orphaned account record.

Account.count # <- We still created another record ‼️
# => 2

Account.last.name
# => "second"

Account.last.supplier == supplier
# => true

supplier.account.name
# => "first"

As we can see, the account is associated with the supplier, but the supplier is still associated with the original account. Rails attempted to nullify the supplier_id on the first account in an effort to maintain the has_one relationship, but our database constraint prevented it from doing so. What we need to do is add an option to delete the association.

class Supplier < ApplicationRecord
  has_one :account, dependent: :destroy
end

Now Rails will know to delete the original account once the new account has been created.

supplier = Supplier.create!
supplier.create_account(name: "first")
supplier.create_account(name: "second")

Account.count
# => 1

supplier.account.name
# => "second"

However, there’s a subtle yet important limitation with our current implementation. We can still associate more than one account with the same supplier if we simply bypass the generated association method!

supplier = Supplier.create!
supplier.create_account(name: "first")

Account.create(name: "second", supplier: supplier) # <- We can still create another record ‼️
# => #<Account>

supplier.account.name
# => "second"

As you can see, there’s nothing preventing us from creating another account record if we avoid using the create_account method. This is because we’re not enforcing this constraint at the database. The has_one API simply provides some convenience methods, but it does not actually enforce the relationship.

To fix this, we need to add a uniqueness constraint in the database. This is briefly mentioned in the Guides.

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. In this case, the column definition might look like this:

class CreateAccounts < ActiveRecord::Migration[7.0]
  def change
    create_table :accounts do |t|
      t.string :name
-     t.belongs_to :supplier, null: false, foreign_key: true
+     t.belongs_to :supplier, null: false, index: { unique: true }, foreign_key: true

      t.timestamps
    end
  end
end

Now, if we try to create another association without the association methods, we’ll raise an error.

supplier = Supplier.create!
supplier.create_account(name: "first")

Account.create(name: "second", supplier: supplier) # <- We can no longer create another record ✅
# => ActiveRecord::RecordNotUnique

Account.count
# => 1

Additionally, we’ll also raise an error if we use the association methods. This is valuable since it’s possible for an application to make this call in multiple places, such as background jobs or tasks.

supplier = Supplier.create!
supplier.create_account(name: "first")

supplier.create_account(name: "second") # <- We can no longer create another record ✅
# => ActiveRecord::RecordNotUnique

Account.count
# => 1

So, the next time you’re thinking of implementing a has_one relationship, consider what we just reviewed. Rails itself does not guarantee a 1:1 relationship. That responsibility is on you. Pushing that constraint into the database provides a safety net against this often overlooked pitfall.

Want to know more?

Learn about thoughtbot’s Rails services and how we can work together to get your ideas to production.