rails
rails copied to clipboard
`ActiveRecord::Associations::Preloader` ignores `available_records` when association has a scope
When preloading a has_one
association that has a scope and providing available_records
for the association, the available_records
are ignored.
Steps to reproduce
require 'bundler/inline'
gemfile(false) do
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gem 'activerecord', '7.0.3.1'
gem 'sqlite3'
gem 'pry'
gem 'rspec'
end
require 'active_record'
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
ActiveRecord::Base.logger = Logger.new(STDOUT)
ActiveRecord::Schema.define do
create_table :posts do |t|
t.timestamps
end
create_table :comments do |t|
t.belongs_to :post, foreign_key: true
t.timestamps
end
end
class Post < ActiveRecord::Base
has_many :comments
has_one :latest_comment, -> { order(created_at: :desc) }, class_name: 'Comment', foreign_key: :post_id
end
class Comment < ActiveRecord::Base
belongs_to :post
end
def preload_latest_comments(posts)
# these are the relevant comments, the latest per post
comments = Comment.where(post: posts).group(:post_id).having('MAX(created_at)').to_a
# using postgres:
# comments = Comment.where(post: posts).order(created_at: :desc).select('DISTINCT ON (post_id) *').to_a
# this "works", but it won't use `comments` to assign the latest comment per post,
# it will fetch all (!) comments per post and assign
preloader = ActiveRecord::Associations::Preloader.new(records: posts, associations: :latest_comment, available_records: comments)
preloader.call
end
RSpec.describe 'preloading using available_records' do
it 'only uses a single sql query' do
post_1 = Post.create!
post_1_latest_comment = Comment.create!(post: post_1)
post_2 = Post.create!
Comment.create!(post: post_2)
post_2_latest_comment = Comment.create!(post: post_2)
post_3 = Post.create!
Comment.create!(post: post_3)
Comment.create!(post: post_3)
post_3_latest_comment = Comment.create!(post: post_3)
posts = [post_1, post_2, post_3]
queries = []
callback = ->(name, start, finish, message_id, values) { queries << values[:sql] }
# memoize sql queries, should only be one
ActiveSupport::Notifications.subscribed(callback, 'sql.active_record') do
preload_latest_comments(posts)
expect(post_1.latest_comment).to eq(post_1_latest_comment)
expect(post_2.latest_comment).to eq(post_2_latest_comment)
expect(post_3.latest_comment).to eq(post_3_latest_comment)
# this should just be:
# SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (?, ?, ?) GROUP BY "comments"."post_id" HAVING (MAX(created_at))
#
# but will be two:
# SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (?, ?, ?) GROUP BY "comments"."post_id" HAVING (MAX(created_at))
# and
# SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (?, ?, ?) ORDER BY "comments"."created_at" DESC
expect(queries.join("\n")).to eq('SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (?, ?, ?) GROUP BY "comments"."post_id" HAVING (MAX(created_at))')
end
end
end
RSpec::Core::Runner.invoke
Expected behavior
I expect available_records
to be used and no additional sql query to be perfomed.
Actual behavior
available_records
is ignored.
I did some research and this line is why an early return is triggered and the available_records
aren't used: https://github.com/rails/rails/blob/v7.0.3.1/activerecord/lib/active_record/associations/preloader/association.rb#L204
reflection_scope.empty_scope?
return false
because of the has_one
scope option (-> { order(created_at: :desc) }
) so !reflection_scope.empty_scope?
returns true
and the method returns early.
The corresponding code comes from this commit. If I comment that one early return line, it works as expected.
Pingin you @jhawthorn, as you seem to be involved. :)
System configuration
Rails version: 7.0.3.1
Ruby version: 3.1.2
Hey there @tbuehlmann, I think this isn't a proper bug, looking at preloader.rb
file, the comments over the initializer specific dictates that optimization is only applied to single associations with no scopes. And if we think about the association with scope uses a query to go search in database, so a think this is working properly. Also, there's no way to know without querying the database that the available_records
array contains the records that the scoped association would retrieve.
Yep! That's correct. We can't use available records for a scoped association as there's no way to be sure that they match, so this is working as intended.
Please note also that all of ActiveRecord::Associations::Preloader
including available_records
is a private API (though admittedly one that people do use 😅) so is not guaranteed to exist or have any particular behaviour.