Software Developer

Finding an Initially Confusing Result in Rails

Initial Impressions ๐Ÿ”—

We run into an old friend Bob and a new friend Carol on the street. Bob recently got married and changed their last name. We’re meeting Carol for the first time. We update our mental Rolodex of friends during this meeting.

Friend.create(first_name: "Bob", last_name: "Smith")
Friend.where(first_name: "Carol").count
=> 0

# Chance encounter on the street

bob = Friend.find_or_initialize_by(first_name: "Bob") do |friend|
  friend.last_name = "Jones"
end

carol = Friend.find_or_initialize_by(first_name: "Carol") do |friend|
  friend.last_name = "Thompson"
end

What are the values of Carol’s and Bob’s last names in our working memory right now during this encounter?

Carol’s Last Name ๐Ÿ”—

Carol’s last name is Thompson. We have no other friends named Carol, so we create a new object and in the block, set their last name to Thompson.

carol.last_name
=> "Thompson"

Bob’s Last Name ๐Ÿ”—

Bob’s last name is still “Smith”, the value it’s persisted in our database as.

bob.last_name
=> "Smith"

Even though in our block, we set their last name to Jones, it didn’t take affect. It seems we forgot our friend’s new last name! This could lead to an embarrassing situation later on in the conversation. What happened?

Finding The Source of the Confusion ๐Ÿ”—

Thanks to Rails’ documentation, we discover the find_or_initialize_by method is in ActiveRecord::Relation. From there, we can look at the source code of the method:

def find_or_initialize_by(attributes, &block)
  find_by(attributes) || new(attributes, &block)
end

Attributing The Difference ๐Ÿ”—

If we find an existing record by the attributes provided, then we return that record. If not, find_by returns nil and we visit the right-hand side of the expression. That will create a new record with the attributes and pass the block to new. Notice that the block is not executed at all when find_by returns a record.

Mistakenly Blocking Out Our Friend’s New Name ๐Ÿ”—

That explains why Bob’s last name isn’t updated in our memory. We documented the change, but it never took effect. find_or_initialize_by didn’t use the block. The saved representation for Bob returned from the method without executing the block.

When using find_or_initialize_by with a block, pay careful attention. The block will only execute in one of those conditions - when initializing an object.

Clarifying Our Initial Intent ๐Ÿ”—

With this information, let’s consider when and how we want to evaluate the code in the block.

Update Neither Found Nor New Records ๐Ÿ”—

Passing a block to find_or_initialize_by is optional. When the criteria we’re using to find a record is all we want our new record to have, there’s no need to supply the block.

bob = Friend.find_or_initialize_by(first_name: "Bob")

Our goal right now is to change Bob’s last name. This alone does not get us there.

Only Update New Records ๐Ÿ”—

This is what our initial implementation is doing. As a reminder, we have:

bob = Friend.find_or_initialize_by(first_name: "Bob") do |friend|
  friend.last_name = "Jones"
end

Weโ€™re searching for our friend named โ€œBobโ€. When we find one, we get our friend back from the database. When we don’t, we instantiate a new friend and also set their last name as โ€œJonesโ€.

That may be confusing to yourself and others reading this code in the future. It may not be clear when the block executes. As an alternative, we could choose to explicitly call that out. A friend thatโ€™s not persisted will respond to new_record? with true, and we can use that to only update their last name when theyโ€™re new.

bob = Friend.find_or_initialize_by(first_name: "Bob")

if bob.new_record?
  bob.last_name = "Jones"
end

The block allows us to interact with new records beyond setting the attributes to identify the record. Block or not though, this isnโ€™t the end result we want here in this example.

Update Both Found and New Records ๐Ÿ”—

This is the functionality we desire in this particular case. We don’t want to find a friend named “Bob Jones”. We won’t find one. We know Bob’s last name changed. But if we do have a friend Bob stored in our database, we want to update their last name.

We can use find_or_initialize_by so that we have a friend instance, whether we have one stored or not. From there, we can use what we’ve learned in the prior sections. We’ll avoid passing the method a block - and we’ll unconditionally change the returned value to set the last name.

bob = Friend.find_or_initialize_by(first_name: "Bob")
bob.last_name = "Jones"

With this change, we make sure to commit our friendโ€™s new last name to memory. Whether they existed in our system before or not, their last name is now “Jones”.

Finding The Initial Inspiration ๐Ÿ”—

Thanks to Ben Drozdoff for the conversation that led to this post.

Thanks to Matthew Draper for the suggestion to augment this with a solutions-based conclusion, depending on when you expect the code in the block to execute.