The Gnar Company
The Gnar Company

Database Queries vs Database Results

by Andrew Palmer

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.