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.