TL;DR
In order to print a user-friendly return value, the Rails console performs additional steps that aren't
performed during normal execution of the code. This results in ActiveRecord method calls looking like
they execute database queries, when really they just build database queries.
Background
Recently I ran into an issue where my tests were failing for reasons I didn't understand.
Here's my code:
class Service
def update_users_and_then_do_something_else
users.update_all(first_name: "new")
users.each do |user|
OtherService.do_something_else(user)
end
end
def users
@users ||= User.where(first_name: "old")
end
end
And here's the test:
require "rails_helper"
RSpec.describe Service, type: :service do
describe "#update_users_and_then_do_something_else" do
it "passes each user to OtherService" do
user_1 = create(:user, first_name: "old")
user_2 = create(:user, first_name: "old")
allow(OtherService).to receive(:do_something_else)
Service.new.update_users_and_then_do_something_else
expect(OtherService).to have_received(:do_something_else).with(user_1)
expect(OtherService).to have_received(:do_something_else).with(user_2)
end
end
end
Unfortunately, this test always failed:
Service
#update_users_and_then_do_something_else
passes each user to OtherService (FAILED - 1)
Failures:
1) Service#update_users_and_then_do_something_else passes each user to OtherService
Failure/Error: expect(OtherService).to have_received(:do_something_else).with(user_1)
(OtherService (class)).do_something_else(#<User ...>)
expected: 1 time with arguments: (#<User ...>)
received: 0 times
# ./spec/services/service_spec.rb
Finished in 0.66811 seconds (files took 9.93 seconds to load)
1 example, 1 failure
This was confusing to me because I had created two users with attributes that matched the query my
service was performing (first_name: "old"
), but neither of them were being passed to OtherService
.
What's going on?
Problem
Our code is set up to build a database query using ActiveRecord, but the code isn't actually executing
the query yet; that happens when needed, for example when trying to call a method on one of the records,
but until then an ActiveRecord::Relation
is returned. When running that same code in a Rails console,
however, the console (actually, irb
or pry
) calls a method on the ActiveRecord::Relation
that executes
the query in order to print some information about each record. For irb
, that's inspect
:
# ActiveRecord::Relation
def inspect
subject = loaded? ? records : self
entries = subject.take([limit_value, 11].compact.min).map!(&:inspect)
entries[10] = "..." if entries.size == 11
"#<#{self.class.name} [#{entries.join(', ')}]>"
end
# ActiveRecord::Core
# Returns a string like 'Post(id:integer, title:string, body:text)'
def inspect
if self == Base
super
elsif abstract_class?
"#{super}(abstract)"
elsif !connected?
"#{super} (call '#{super}.connection' to establish a connection)"
elsif table_exists?
attr_list = attribute_types.map { |name, type| "#{name}: #{type.type}" } \* ", "
"#{super}(#{attr_list})"
else
"#{super}(Table doesn't exist)"
end
end
With this in mind, @users ||= User.where(first_name: "old")
saves an ActiveRecord::Relation
into
the instance variable @users
, not a collection of records. This is saving the
query, not the users that query returns. This is fine at first when updating records
with users.update_all(first_name: "new")
, but because that operation changes the attribute by which the
query finds users, running it a second time will not be able to find any, and users.each...
will be
iterating through 0 records.
Solution
In order to get this to work, we could do the following:
class Service
def update_users_and_then_do_something_else
users.each do |user|
user.update(first_name: "new")
OtherService.do_something_else(user)
end
end
def users
User.where(first_name: "old")
end
end
Service
#update_users_and_then_do_something_else passes each user to OtherService
Finished in 0.57873 seconds (files took 9.3 seconds to load)
1 example, 0 failures
This is unfortunate because we lose the performance benefit of update_all
, but it ensures that
we're performing both operations (update
and OtherService.do_something_else
) on the same records.
Also, because users
is just a query builder and not the product of executing the query, it's not
necessary or beneficial to memoize that value.
Outcome/Takeaways
The ability to interact seamlessly with the database is one of the utilities Rails provides that's
pretty easy to take for granted, and from my point of view at least, was easy to rely on without
really understanding what was happening. In this case, however, that led to an interesting challenge
that forced me to look under the hood a little bit in order to get things working.