Recently I was tasked to solve a bug on a feature that allows a user to mass import the relationships of an existing record of a model via a yml file.
Lets say you have 2 models, course
and professor
as:
class Course < ApplicationRecord
has_one :professor
accepts_nested_attributes_for :professor
end
class Professor < ApplicationRecord
belongs_to :course
end
And the user can update existing records with a yml like:
---
name: Math 101
:professor_attributes:
name: John Doe
The importer just parse this yml into a object like:
params = {
name: 'Math 101',
professor_attributes: {
name: 'John Doe'
}
}
For the sake of the example, lets say we want to update course with id 1, so the operation the importer is doing is:
course = Course.find(1)
course.assign_attributes(params)
course.save
It seems pretty straightforward right? Well, unfortunately not in all cases, so let's break this out:
A professor's record for existing course doesn't exist:
This case works. If you run this on a rails console you'll get:
course.assign_attributes(params)
course.save
TRANSACTION (0.2ms) BEGIN
Professor Create (3.0ms) INSERT INTO "professors" ("name", "course_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id" [["name", "John Doe"], ["course_id", 1], ["created_at", "2023-10-30 06:55:41.912606"], ["updated_at", "2023-10-30 06:55:41.912606"]]
TRANSACTION (2.3ms) COMMIT
A new record is inserted for the relation and we are all happy.
A professor's record for existing course exists, but I don't know it's id:
Here's where things start to get tricky. As you can wonder, the system user doesn't have to know the internals of a database, therefore they don't know the professors primary key. That's why the yml file doesn't have it.
As we have here a has_one
relation, you can think "well, why do I need to know the professors primary key? it's only one record anyway!".
Well, lets see what rails do in this case:
course.assign_attributes(params)
Professor Load (0.4ms) SELECT "professors".* FROM "professors" WHERE "professors"."course_id" = $1 LIMIT $2 [["course_id", 1], ["LIMIT", 1]]
TRANSACTION (0.1ms) BEGIN
Professor Update (0.7ms) UPDATE "professors" SET "course_id" = $1, "updated_at" = $2 WHERE "professors"."id" = $3 [["course_id", nil], ["updated_at", "2023-10-30 07:01:44.880614"], ["id", 5]]
TRANSACTION (2.6ms) COMMIT
Weird right? with only calling assign_attributes
rails is going to the database to execute an update setting professor's foreign key to null.
If then, you run save:
TRANSACTION (0.2ms) BEGIN
Professor Create (0.9ms) INSERT INTO "professors" ("name", "course_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id" [["name", "John Doe"], ["course_id", 1], ["created_at", "2023-10-30 07:05:05.858188"], ["updated_at", "2023-10-30 07:05:05.858188"]]
TRANSACTION (1.8ms) COMMIT
Wow, rails is now inserting a new brand record with the "updated" fields, so at the end you will have a duplicated record in the database:
select id,name,course_id from professors;
id | name | course_id
----+----------+-----------
5 | Jhon Doe | <- null
6 | Jhon Doe | 1
So you'll end up 2 records, one of them with its foreign key in null, possibly raising an active record exception if you have strict constraints on the foreign keys.
This of course will not happen if you include all proper ids in the params hash you are passing to assign_attributes as:
params = {
name: 'Math 101',
professor_attributes: {
id: 5
name: 'John Doe'
}
}
Conclusion
Be very careful when using assign_attributes
with accepts_nested_attributes_for
's models, and don't trust on the fact that has_one relations should be easy to locate. If you can't pass proper primary/foreign keys to the hash object you are passing to assign_attributes
it is better to manually load the relation and update it separately like:
params = {
name: 'Math 101',
professor_attributes: {
name: 'John Doe'
}
}
course = Course.find(1)
if course.professor
params[:professor_attributes][:id] = course.id
end
course.assign_attributes(params)
course.save
Hope you find this useful.
Top comments (2)
Shouldn’t it be
If course.professor
params[:professor_attributes][:id] = course.professor.id
end
?
From what I understand the problem is when an association exists otherwise it would work without issue, right?
Julio, thanks for your note. Yes, you're right, only if the association exists we have to ensure the primary key is present in the params hash to avoid these annoying record duplications.
Just made the correction in the article. Thanks again!