Has_one :through always queries the database even when intermediate and target associations are already loaded

Hey everyone :waving_hand:,

My lead dev @jplot and I ran into something that surprised us while profiling N+1s in a production app. We have a pretty classic grandparent pattern — Account → Report → Issue — and we noticed that issue.account (defined as has_one :account, through: :report) always hits the database, even when the entire object graph is already loaded via includes.

The weird part: issue.report.account (manual chaining) works perfectly fine — zero queries. Same for delegate :account, to: :report. It’s only the has_one :through path that ignores what’s already in memory.

We dug into it a bit and we’re fairly confident this is a bug (or at least a missing optimization) in how HasOneThroughAssociation resolves. We’ve tested on Rails 7.1 and 8.1.2, same behavior on both. Adding inverse_of everywhere doesn’t help either.

Reproduction

Here’s a self-contained script you can copy-paste and run directly:

# frozen_string_literal: true
require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"
  gem "rails", "~> 8.0"
  gem "sqlite3"
end

require "active_record"
require "minitest/autorun"

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")

$queries = []
ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
  $queries << payload[:sql] unless payload[:name] == "SCHEMA"
end

ActiveRecord::Schema.define do
  create_table :accounts, force: true
  create_table :reports, force: true do |t|
    t.integer :account_id
  end
  create_table :issues, force: true do |t|
    t.integer :report_id
  end
end

class Account < ActiveRecord::Base
  has_many :reports, inverse_of: :account
end

class Report < ActiveRecord::Base
  belongs_to :account, inverse_of: :reports
  has_many :issues, inverse_of: :report
end

class Issue < ActiveRecord::Base
  belongs_to :report, inverse_of: :issues
  has_one :account, through: :report
  delegate :account, to: :report, prefix: :delegated
end

class HasOneThroughTest < Minitest::Test
  def setup
    account = Account.create!
    report = Report.create!(account: account)
    Issue.create!(report: report)
  end

  # FAILS — has_one :through fires a query even though everything is loaded
  def test_has_one_through_reuses_loaded_associations
    accounts = Account.includes(reports: :issues).all.to_a
    issue = accounts.first.reports.first.issues.first

    # Both intermediate and target are already loaded:
    assert issue.association(:report).loaded?,         "report should be loaded"
    assert issue.report.association(:account).loaded?, "report.account should be loaded"

    $queries.clear
    issue.account  # fires SELECT despite everything being in memory!

    assert_equal 0, $queries.length,
      "has_one :through should reuse the loaded association, got: #{$queries.inspect}"
  end

  # PASSES — delegate just walks the Ruby objects
  def test_delegate_reuses_loaded_associations
    accounts = Account.includes(reports: :issues).all.to_a
    issue = accounts.first.reports.first.issues.first

    $queries.clear
    issue.delegated_account

    assert_equal 0, $queries.length
  end

  # PASSES — manual chaining works fine too
  def test_manual_chaining_reuses_loaded_associations
    accounts = Account.includes(reports: :issues).all.to_a
    issue = accounts.first.reports.first.issues.first

    $queries.clear
    issue.report.account

    assert_equal 0, $queries.length
  end
end

What happens

How you access it Queries fired
issue.account (has_one :through) 1 — N+1!
issue.delegated_account (delegate) 0
issue.report.account (manual chain) 0

So report is loaded, report.account is loaded, but has_one :through goes straight to the DB anyway:

SELECT "accounts".* FROM "accounts"
  INNER JOIN "reports" ON "accounts"."id" = "reports"."account_id"
  WHERE "reports"."id" = ? LIMIT ?

What I think is going on

It looks like HasOneThroughAssociation builds a scope and fires the query without ever checking if the intermediate association is already loaded and the target is reachable through it. The inverse_of mechanism — which works great for direct belongs_to/has_one — doesn’t kick in here.

Why I think this matters

This “grandparent shortcut” pattern is really common in deep hierarchies. You add has_one :through because it feels like the idiomatic Rails way to express the relationship — and you’d expect it to behave like issue.report.account under the hood. But instead it silently introduces N+1s that no amount of includes can fix.

The workaround is delegate, which works… but then you lose the ability to use that association in scopes, where clauses, joins, etc. So you end up choosing between query efficiency and query composability, which feels like a false tradeoff.

What we’d expect

When has_one :through is accessed and both the intermediate (report) and the target (report.account) are already loaded in memory, ActiveRecord should just return the in-memory object — the same way direct associations do with inverse_of.

Environment

  • Reproduced on Rails 7.1.0 and 8.1.2
  • Ruby 3.2+
  • PostgreSQL (also reproduced with SQLite — behavior is adapter-independent)

Curious if others have run into this too! :grinning_face:

4 Likes

Quick update on this: a PR has just been opened to fix this exact behavior!

You can check it out here: has_one :through ignores preloaded associations, fires N+1 query #56978 by nicolasva · Pull Request #56985 · rails/rails · GitHub. I’ll be keeping an eye on it and might try to test the branch against my app to confirm it resolves the N+1/unnecessary query issue.

1 Like