Database Queries vs Database Results

Engineering Insights

#
Min Read
Published On
March 13, 2025
Updated On
March 14, 2025
Database Queries vs Database Results

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

Source

# 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

Source

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.

Author headshot
Written by
, The Gnar Company

Related Insights

See All Articles
Engineering Insights
AI Evals Are Not So Different From the Tests You Already Write

AI Evals Are Not So Different From the Tests You Already Write

Moving from deterministic code to LLMs doesn't mean abandoning your testing rig—it means evolving it. Discover why "evals" are essentially the automated tests of the probabilistic world and how to apply the testing wisdom you already have to ship AI features with total confidence.
Product Insights
Thoughtbot Alternatives: Choosing the Right Software Development Partner in 2026

Thoughtbot Alternatives: Choosing the Right Software Development Partner in 2026

Thoughtbot alternatives for software development in 2026. The Gnar offers guaranteed milestone pricing, a 12-month bug-free warranty, and a 100% US-based team.
Product Insights
How Much Does Custom Software Development Cost? (The Real Answer)

How Much Does Custom Software Development Cost? (The Real Answer)

Most software projects go over budget because they're priced before the problem is understood. Learn how structured discovery gives you a guaranteed build price before development starts.
Previous
Next
See All Articles