Skip to content

Instantly share code, notes, and snippets.

@thisismydesign
Last active June 12, 2019 12:12
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thisismydesign/b08ba0ee3c1862ef87effe0e25386267 to your computer and use it in GitHub Desktop.
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
# 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
$ 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