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.
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?
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.
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.
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.