Last active
June 12, 2019 12:12
-
-
Save thisismydesign/b08ba0ee3c1862ef87effe0e25386267 to your computer and use it in GitHub Desktop.
Load first records of ordered association for a collection without N+1 queries
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# https://github.com/rails/rails/issues/6769 | |
# https://github.com/rails/rails/issues/10621 | |
# https://stackoverflow.com/questions/19353507/eager-loading-the-first-record-of-an-association | |
# https://stackoverflow.com/questions/29142478/eager-load-only-first-associations-with-activerecord | |
# https://stackoverflow.com/questions/46415817/eager-loading-not-working-with-order-clause-in-rails | |
# https://stackoverflow.com/questions/52889750/eager-loading-with-scope-in-rails | |
# https://stackoverflow.com/questions/30056163/eager-loading-in-deep-level-nested-association | |
# https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#module-ActiveRecord::Associations::ClassMethods-label-Eager+loading+of+associations | |
# https://stackoverflow.com/questions/29804377/rails-4-eager-load-limit-subquery | |
# https://stackoverflow.com/questions/6477614/how-do-you-do-eager-loading-with-limits | |
begin | |
require "bundler/inline" | |
rescue LoadError => e | |
$stderr.puts "Bundler version 1.10 or later is required. Please update your Bundler" | |
raise e | |
end | |
a = gemfile(true) do | |
source "https://rubygems.org" | |
gem "activerecord", "5.2.0" | |
gem "sqlite3", "1.3.13" | |
gem "rake", "~> 10.0" | |
gem "rspec", "~> 3.0" | |
gem "faker" | |
gem "factory_bot" | |
end | |
require "active_record" | |
require "rspec" | |
sqlite3_config = { | |
"development" => { | |
"adapter" => "sqlite3", | |
"database" => "development.sqlite3" | |
} | |
} | |
def load_active_record_tasks(database_configuration:, root:, db_dir: root, migrations_paths: [root], env: "development", seed_loader: nil) | |
include ActiveRecord::Tasks | |
DatabaseTasks.database_configuration = database_configuration | |
DatabaseTasks.root = root | |
DatabaseTasks.db_dir = db_dir | |
DatabaseTasks.migrations_paths = migrations_paths | |
DatabaseTasks.env = env | |
DatabaseTasks.seed_loader = seed_loader | |
task :environment do | |
ActiveRecord::Base.configurations = DatabaseTasks.database_configuration | |
ActiveRecord::Base.establish_connection DatabaseTasks.env.to_sym | |
end | |
load 'active_record/railties/databases.rake' | |
end | |
root = Pathname.new(".") | |
sqlite3_config = { | |
"development" => { | |
"adapter" => "sqlite3", | |
"database" => "development.sqlite3" | |
} | |
} | |
load_active_record_tasks(database_configuration: sqlite3_config, root: root, db_dir: root) | |
def define_db | |
ActiveRecord::Schema.define do | |
create_table :users do |t| | |
t.timestamps | |
end | |
create_table :activities do |t| | |
t.timestamps | |
end | |
create_table :likes do |t| | |
t.references :likable, polymorphic: true | |
t.references :liker, polymorphic: true | |
t.timestamps | |
end | |
end | |
end | |
class User < ActiveRecord::Base | |
has_many :likes, as: :liker | |
end | |
class Activity < ActiveRecord::Base | |
has_many :likes, as: :likable | |
# WARNING: having `.limit(1)` in the scope breaks ordering with every approach and breaks the association with some | |
has_one :first_like, -> { order(created_at: :asc) }, class_name: "Like", as: :likable | |
end | |
class Like < ActiveRecord::Base | |
belongs_to :likable, polymorphic: true | |
belongs_to :liker, polymorphic: true | |
default_scope { order(created_at: :asc) } | |
end | |
FactoryBot.define do | |
factory :user do | |
id { Faker::Number.unique.number(9) } | |
end | |
factory :activity do | |
id { Faker::Number.unique.number(9) } | |
end | |
factory :like do | |
id { Faker::Number.unique.number(9) } | |
liker factory: :user | |
likable factory: :activity | |
end | |
end | |
RSpec.configure do |config| | |
config.formatter = "documentation" | |
config.include FactoryBot::Syntax::Methods | |
config.before(:all) do | |
Rake::Task["db:drop"].invoke | |
Rake::Task["db:create"].invoke | |
define_db | |
end | |
config.after(:all) do | |
Rake::Task["db:drop"].invoke | |
end | |
end | |
RSpec.describe "association loading", :aggregate_failures do | |
def create_liked_activities(count = 1, old_created_at = 1.year.ago) | |
count.times do | |
activity = create(:activity) | |
create(:like, likable: activity, created_at: old_created_at) | |
create_list(:like, like_count - 1, likable: activity) | |
end | |
end | |
let(:old_created_at) { 1.year.ago.iso8601 } | |
let(:like_count) { 3 } | |
let(:activity_count) { 3 } | |
before do | |
create_liked_activities(activity_count, old_created_at) | |
end | |
after do | |
Activity.all.destroy_all | |
end | |
describe "entities" do | |
it "creates correct number of entities" do | |
expect(Activity.all.count).to eq(activity_count) | |
expect(Like.all.count).to eq(activity_count * like_count) | |
end | |
end | |
RSpec.shared_examples "respects association scope" do | |
it "respects order defined in association or default_scope" do | |
load_activities.each do |activity| | |
expect(activity.first_like&.created_at).to eq(old_created_at) | |
end | |
end | |
it "respects order defined in the query" do | |
load_activities.order("likes.created_at asc").each do |activity| | |
expect(activity.first_like&.created_at).to eq(old_created_at) | |
end | |
end | |
it "fetches likes for all records in a collection" do | |
expect(load_activities.map(&:first_like).reject(&:blank?).count).to eq(activity_count) | |
end | |
end | |
describe "N+1 load" do | |
let(:load_activities) { Activity.all } | |
it "can find record via has_many association" do | |
load_activities.each do |activity| | |
expect(activity.likes.order(created_at: :asc).first.created_at).to eq(old_created_at) | |
end | |
end | |
it "respects order defined in association or default_scope" do | |
load_activities.each do |activity| | |
expect(activity.first_like.created_at).to eq(old_created_at) | |
end | |
end | |
end | |
describe "eager load" do | |
let(:load_activities) { Activity.eager_load(:first_like).all } | |
it_behaves_like "respects association scope" | |
end | |
describe "includes" do | |
let(:load_activities) { Activity.includes(:first_like).all } | |
it_behaves_like "respects association scope" | |
end | |
describe "includes references" do | |
let(:load_activities) { Activity.includes(:first_like).references(:first_like).all } | |
it_behaves_like "respects association scope" | |
end | |
describe "includes preload" do | |
let(:load_activities) { Activity.includes(:first_like).preload(:first_like).all } | |
it_behaves_like "respects association scope" | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
$ rspec association_loading.rb | |
Fetching gem metadata from https://rubygems.org/........ | |
Resolving dependencies... | |
Using rake 10.5.0 | |
Using concurrent-ruby 1.1.5 | |
Using i18n 1.6.0 | |
Using minitest 5.11.3 | |
Using thread_safe 0.3.6 | |
Using tzinfo 1.2.5 | |
Using activesupport 5.2.0 | |
Using activemodel 5.2.0 | |
Using arel 9.0.0 | |
Using activerecord 5.2.0 | |
Using bundler 2.0.1 | |
Using diff-lcs 1.3 | |
Using factory_bot 5.0.2 | |
Using faker 1.9.3 | |
Using rspec-support 3.8.2 | |
Using rspec-core 3.8.0 | |
Using rspec-expectations 3.8.4 | |
Using rspec-mocks 3.8.0 | |
Using rspec 3.8.0 | |
Using sqlite3 1.3.13 | |
association loading | |
Dropped database 'development.sqlite3' | |
Created database 'development.sqlite3' | |
-- create_table(:users) | |
-> 0.0057s | |
-- create_table(:activities) | |
-> 0.0053s | |
-- create_table(:likes) | |
-> 0.0184s | |
entities | |
creates correct number of entities | |
N+1 load | |
can find record via has_many association | |
respects order defined in association or default_scope | |
eager load | |
behaves like respects association scope | |
respects order defined in association or default_scope (FAILED - 1) | |
respects order defined in the query | |
fetches likes for all records in a collection | |
includes | |
behaves like respects association scope | |
respects order defined in association or default_scope | |
respects order defined in the query | |
fetches likes for all records in a collection | |
includes references | |
behaves like respects association scope | |
respects order defined in association or default_scope (FAILED - 2) | |
respects order defined in the query | |
fetches likes for all records in a collection | |
includes preload | |
behaves like respects association scope | |
respects order defined in association or default_scope | |
respects order defined in the query | |
fetches likes for all records in a collection |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment