Be careful when using assign_attributes with has_one relations in Rails 7
Tags: rails, webdev, database, development
Published: 2023-10-30 07:20:36

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.