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.