DEV Community

Hernan Velasquez
Hernan Velasquez

Posted on • Edited on

Be careful when using assign_attributes with has_one relations in Rails 7

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
Enter fullscreen mode Exit fullscreen mode

And the user can update existing records with a yml like:

---
name: Math 101
:professor_attributes:
  name: John Doe
Enter fullscreen mode Exit fullscreen mode

The importer just parse this yml into a object like:

params = {
  name: 'Math 101',
  professor_attributes: {
    name: 'John Doe'
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
  }
}
Enter fullscreen mode Exit fullscreen mode

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    
Enter fullscreen mode Exit fullscreen mode

Hope you find this useful.

Top comments (2)

Collapse
 
julio_vidalgarcia_ef8362 profile image
Julio Vidal Garcia

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?

Collapse
 
hernamvel profile image
Hernan Velasquez

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!