Avoid first or create when working with attributes that are not unique per record

This is part of the Semicolon&Sons Code Diary - consisting of lessons learned on the job. You're in the rails category.

Last Updated: 2024-04-25

My User model has both mandatory email and store attributes (in the sense that neither could be null). Therefore any time I created a user, I had to make sure both attributes were included:

User.where(
  email: ...,
  store: ...
).first_or_create!

One day an Australian customer shopped in my Canadian store and called an endpoint containing this code. Even though a user with their email existed, my code couldn't find a matching {email, store} tuple (since it had {email@example.com,australia} but not {email@example.com,canada}), therefore it tried to create a new user, but failed on the email uniqueness constraint at the DB level.

I had assumed a user stays in one store forever, which, while mostly true, wasn't true in this case.

More deeply, I should have realized that, even though both attributes were necessary, only one of them uniquely identifies the row in question. Therefore I should have refactored to perform the finder logic based on the database uniqueness constraint, only adding the mandatory store attribute when actually creating a record (if necessary)

user = User.find_by_email(params[:email]) || User.create!(
  email: params[:email],
  store: current_store
)

Lessons

Do not use first_or_create with any attributes that might take on multiple values for a given entity. Instead limit it to rows that will always be unique for each record.