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.