Skip to main content
Published in Engineering Insights

Database Queries vs Database Results

Database Queries vs Database Results
Table of contents
  1. TL;DR
  2. Background
  3. Problem
  4. Solution
  5. Outcome/Takeaways

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:

Ruby
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:

Ruby
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:

Ruby
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:

Ruby
# 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

Ruby
# 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:

Ruby
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
Code
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.

Frequently asked questions

Why does the query seem to run in the Rails console?

The console calls a method on the ActiveRecord::Relation to print information about its records. In irb, that method is inspect, which executes the query for display.

Why does the original service skip the follow-up action?

It stores a query for users whose first_name is "old". update_all changes that value to "new" before users.each runs the query, so there are no matching records left to iterate over.

How does the proposed fix work?

It iterates over the matching users, updates each one and then calls OtherService.do_something_else for that user. This ensures both operations use the same records, at the cost of losing the performance benefit of update_all.

Does the query builder need to be memoized here?

No. The users method builds a query rather than storing the records returned by it. Memoizing that value is not necessary or beneficial in this example.

Stay In The Loop

Never miss an update. Get the latest blogs, webinars, and expert insights straight to your inbox from The Gnar.