├── Rakefile ├── .rspec ├── .travis.yml ├── Gemfile ├── lib ├── eager_group │ ├── version.rb │ ├── preloader │ │ ├── has_many.rb │ │ ├── many_to_many.rb │ │ ├── has_many_through_many.rb │ │ ├── has_many_through_belongs_to.rb │ │ └── aggregation_finder.rb │ ├── definition.rb │ └── preloader.rb ├── eager_group.rb └── active_record │ └── with_eager_group.rb ├── bin ├── setup └── console ├── .gitignore ├── .github └── workflows │ └── main.yml ├── MIT-LICENSE ├── CHANGELOG.md ├── eager_group.gemspec ├── spec ├── support │ ├── schema.rb │ ├── data.rb │ └── models.rb ├── spec_helper.rb └── integration │ └── eager_group_spec.rb ├── benchmark.rb └── README.md /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.3.0 4 | env: 5 | - DB=sqlite 6 | script: bundle exec rspec spec 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in eager_group.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/eager_group/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EagerGroup 4 | VERSION = '0.10.0' 5 | end 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | .idea 11 | .tool-versions 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | test: 8 | name: test 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Set up Ruby 13 | uses: ruby/setup-ruby@v1 14 | with: 15 | ruby-version: 3.1 16 | bundler-cache: true 17 | - name: Run tests 18 | run: bundle exec rspec 19 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'eager_group' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start 16 | -------------------------------------------------------------------------------- /lib/eager_group/preloader/has_many.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EagerGroup 4 | class Preloader 5 | class HasMany < AggregationFinder 6 | def group_by_foreign_key 7 | reflection.foreign_key 8 | end 9 | 10 | def aggregate_hash 11 | scope = reflection.klass.all.tap{|query| query.merge!(definition_scope) if definition_scope } 12 | 13 | scope.where(group_by_foreign_key => record_ids). 14 | where(polymophic_as_condition). 15 | group(group_by_foreign_key). 16 | send(definition.aggregation_function, definition.column_name) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/eager_group/preloader/many_to_many.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EagerGroup 4 | class Preloader 5 | class ManyToMany < AggregationFinder 6 | def group_by_foreign_key 7 | "#{reflection.join_table}.#{reflection.foreign_key}" 8 | end 9 | 10 | def aggregate_hash 11 | scope = klass.joins(reflection.name).tap{|query| query.merge!(definition_scope) if definition_scope} 12 | 13 | scope.where(group_by_foreign_key => record_ids). 14 | where(polymophic_as_condition). 15 | group(group_by_foreign_key). 16 | send(definition.aggregation_function, definition.column_name) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/eager_group/preloader/has_many_through_many.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EagerGroup 4 | class Preloader 5 | class HasManyThroughMany < AggregationFinder 6 | def group_by_foreign_key 7 | "#{reflection.through_reflection.name}.#{reflection.through_reflection.foreign_key}" 8 | end 9 | 10 | def aggregate_hash 11 | scope = klass.joins(reflection.name).tap{|query| query.merge!(definition_scope) if definition_scope } 12 | 13 | scope.where(group_by_foreign_key => record_ids). 14 | where(polymophic_as_condition). 15 | group(group_by_foreign_key). 16 | send(definition.aggregation_function, definition.column_name) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/eager_group/definition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EagerGroup 4 | class Definition 5 | attr_reader :association, :column_name, :scope 6 | 7 | def initialize(association, aggregate_function, column_name, scope) 8 | @association = association 9 | @aggregate_function = aggregate_function 10 | @column_name = column_name 11 | @scope = scope 12 | end 13 | 14 | def aggregation_function 15 | return :maximum if @aggregate_function.to_sym == :last_object 16 | return :minimum if @aggregate_function.to_sym == :first_object 17 | 18 | @aggregate_function 19 | end 20 | 21 | def need_load_object 22 | %i[first_object last_object].include?(@aggregate_function.to_sym) 23 | end 24 | 25 | def default_value 26 | %i[first_object last_object].include?(@aggregate_function.to_sym) ? nil : 0 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/eager_group/preloader/has_many_through_belongs_to.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EagerGroup 4 | class Preloader 5 | class HasManyThroughBelongsTo < AggregationFinder 6 | def group_by_foreign_key 7 | "#{reflection.table_name}.#{reflection.through_reflection.klass.reflect_on_association(reflection.name).foreign_key}" 8 | end 9 | 10 | def aggregate_hash 11 | scope = reflection.klass.all.tap{|query| query.merge!(definition_scope) if definition_scope } 12 | 13 | scope.where(group_by_foreign_key => record_ids). 14 | where(polymophic_as_condition). 15 | group(group_by_foreign_key). 16 | send(definition.aggregation_function, definition.column_name) 17 | end 18 | 19 | def group_by_key 20 | reflection.through_reflection.foreign_key 21 | end 22 | 23 | def polymophic_as_condition 24 | reflection.type ? { reflection.name => { reflection.type => reflection.through_reflection.klass.base_class.name } } : [] 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 - 2022 Richard Huang (flyerhzm@gmail.com) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/eager_group/preloader/aggregation_finder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EagerGroup 4 | class Preloader 5 | class AggregationFinder 6 | attr_reader :klass, :reflection, :definition, :arguments, :record_ids 7 | 8 | def initialize(klass, definition, arguments, records) 9 | @klass = klass 10 | @definition = definition 11 | @reflection = @klass.reflect_on_association(definition.association) 12 | @arguments = arguments 13 | @records = records 14 | end 15 | 16 | def definition_scope 17 | reflection.klass.instance_exec(*arguments, &definition.scope) if definition.scope 18 | end 19 | 20 | def record_ids 21 | @record_ids ||= @records.map { |record| record.send(group_by_key) } 22 | end 23 | 24 | def group_by_key 25 | @klass.primary_key 26 | end 27 | 28 | def aggregate_hash 29 | raise NotImplementedError, 'Method "aggregate_hash" must be implemented in subclass' 30 | end 31 | 32 | private 33 | 34 | def polymophic_as_condition 35 | reflection.type ? { reflection.name => { reflection.type => @klass.base_class.name } } : [] 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Next Release 2 | 3 | ## 0.10.0 (12/28/2022) 4 | 5 | * Add STI support, use `class_attribute` to prevent subclasses affect each other 6 | 7 | ## 0.9.1 (12/15/2022) 8 | 9 | * Fix eager group fetch multi definitions 10 | 11 | ## 0.9.0 (12/04/2022) 12 | 13 | * Support `has_many` through `belongs_to` 14 | 15 | ## 0.8.2 (09/22/2022) 16 | 17 | * Add MIT-LICENSE 18 | 19 | ## 0.8.1 (10/25/2019) 20 | 21 | * Fix for `has_many :through` 22 | 23 | ## 0.8.0 (10/21/2019) 24 | 25 | * Support `has_and_belongs_to_many` 26 | 27 | ## 0.7.2 (10/10/2019) 28 | 29 | * Simplify `association_klass` for `first_object` and `last_object` 30 | 31 | ## 0.7.1 (08/23/2019) 32 | 33 | * Set `eager_group_definitions` by `mattr_accessor` 34 | 35 | ## 0.7.0 (08/22/2019) 36 | 37 | * Add `first_object` and `last_object` aggregation 38 | 39 | ## 0.6.1 (03/05/2018) 40 | 41 | * Skip preload when association is empty 42 | 43 | ## 0.6.0 (12/15/2018) 44 | 45 | * Support hash as `eager_group` argument 46 | * Support rails 5.x 47 | 48 | ## 0.5.0 (09/22/2016) 49 | 50 | * Add magic method for one record 51 | 52 | ## 0.4.0 (05/18/2016) 53 | 54 | * Support scope arguments 55 | 56 | ## 0.3.0 (10/11/2015) 57 | 58 | * Support polymorphic association 59 | 60 | ## 0.2.0 (07/11/2015) 61 | 62 | * Add support to `has_many :through` 63 | 64 | ## 0.1.0 (06/29/2015) 65 | 66 | * First release 67 | -------------------------------------------------------------------------------- /eager_group.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'eager_group/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'eager_group' 9 | spec.version = EagerGroup::VERSION 10 | spec.authors = ['Richard Huang'] 11 | spec.email = ['flyerhzm@gmail.com'] 12 | 13 | spec.summary = 'Fix n+1 aggregate sql functions' 14 | spec.description = 'Fix n+1 aggregate sql functions for rails' 15 | spec.homepage = 'https://github.com/flyerhzm/eager_group' 16 | 17 | spec.license = 'MIT' 18 | 19 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 20 | spec.bindir = 'exe' 21 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 22 | spec.require_paths = ['lib'] 23 | 24 | spec.add_development_dependency 'activerecord' 25 | spec.add_development_dependency 'activerecord-import' 26 | spec.add_development_dependency 'activesupport' 27 | spec.add_development_dependency 'benchmark-ips' 28 | spec.add_development_dependency 'bundler' 29 | spec.add_development_dependency 'rake', '~> 10.0' 30 | spec.add_development_dependency 'rspec', '~> 3.3' 31 | spec.add_development_dependency 'sqlite3' 32 | spec.add_development_dependency 'pry' 33 | end 34 | -------------------------------------------------------------------------------- /spec/support/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') 4 | 5 | ActiveRecord::Schema.define do 6 | self.verbose = false 7 | 8 | create_table :users, force: true do |t| 9 | t.string :name 10 | t.timestamps null: false 11 | end 12 | 13 | create_table :posts, force: true do |t| 14 | t.string :title 15 | t.string :body 16 | t.integer :user_id 17 | t.timestamps null: false 18 | end 19 | 20 | create_table :comments, force: true do |t| 21 | t.string :body 22 | t.string :status 23 | t.string :author_type 24 | t.integer :author_id 25 | t.integer :rating 26 | t.integer :post_id 27 | t.timestamps null: false 28 | end 29 | 30 | create_table :teachers, force: true do |t| 31 | t.string :name 32 | t.timestamps null: false 33 | end 34 | 35 | create_table :students, force: true do |t| 36 | t.string :name 37 | t.timestamps null: false 38 | end 39 | 40 | create_table :students_teachers, force: true do |t| 41 | t.integer :student_id 42 | t.integer :teacher_id 43 | end 44 | 45 | create_table :homeworks, force: true do |t| 46 | t.integer :teacher_id 47 | t.integer :student_id 48 | end 49 | 50 | create_table :vehicles, force: true do |t| 51 | t.string :type 52 | t.string :name 53 | t.timestamps null: false 54 | end 55 | 56 | create_table :passengers, force: true do |t| 57 | t.integer :vehicle_id 58 | t.string :name 59 | t.integer :age 60 | 61 | t.timestamps null: false 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/support/data.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | teacher1 = Teacher.create name: 'Teacher 1' 4 | teacher2 = Teacher.create name: 'Teacher 2' 5 | teacher3 = Teacher.create name: 'Teacher 3' 6 | student1 = Student.create name: 'Student 1' 7 | student2 = Student.create name: 'Student 2' 8 | student3 = Student.create name: 'Student 3' 9 | student4 = Student.create name: 'Student 4' 10 | teacher1.students = [student1] 11 | teacher2.students = [student2, student3, student4] 12 | 13 | user1 = User.create(name: 'Alice') 14 | user2 = User.create(name: 'Bob') 15 | 16 | post1 = user1.posts.create(title: 'First post!') 17 | post2 = user2.posts.create(title: 'Second post!') 18 | post3 = user2.posts.create(title: 'Third post!') 19 | 20 | post1.comments.create(status: 'created', rating: 4, author: student1) 21 | post1.comments.create(status: 'approved', rating: 5, author: student1) 22 | post1.comments.create(status: 'deleted', rating: 0, author: student2) 23 | 24 | post2.comments.create(status: 'approved', rating: 3, author: student1) 25 | post2.comments.create(status: 'approved', rating: 5, author: teacher1) 26 | 27 | homework1 = Homework.create(student: student1, teacher: teacher1) 28 | homework1 = Homework.create(student: student2, teacher: teacher1) 29 | 30 | bus = SchoolBus.create(name: 'elementary bus') 31 | sedan = Sedan.create(name: 'honda accord') 32 | 33 | bus.passengers.create(name: 'Ruby', age: 7) 34 | bus.passengers.create(name: 'Mike', age: 8) 35 | bus.passengers.create(name: 'Zach', age: 9) 36 | bus.passengers.create(name: 'Jacky', age: 10) 37 | 38 | sedan.passengers.create(name: 'Ruby', age: 7) 39 | sedan.passengers.create(name: 'Mike', age: 8) 40 | sedan.passengers.create(name: 'Zach', age: 9) 41 | sedan.passengers.create(name: 'Jacky', age: 10) 42 | -------------------------------------------------------------------------------- /lib/eager_group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/module/attribute_accessors' 4 | require 'active_support/core_ext/class/attribute' 5 | require 'active_support/core_ext/hash' 6 | require 'eager_group/version' 7 | 8 | module EagerGroup 9 | autoload :Preloader, 'eager_group/preloader' 10 | autoload :Definition, 'eager_group/definition' 11 | 12 | def self.included(base) 13 | base.extend ClassMethods 14 | base.class_eval do 15 | class_attribute :eager_group_definitions, instance_writer: false, default: {}.with_indifferent_access 16 | end 17 | end 18 | 19 | module ClassMethods 20 | #mattr_accessor :eager_group_definitions, default: {} 21 | 22 | def add_eager_group_definition(ar, definition_name, definition) 23 | ar.eager_group_definitions = self.eager_group_definitions.except(definition_name).merge!(definition_name => definition) 24 | end 25 | 26 | # class Post 27 | # define_eager_group :comments_avergage_rating, :comments, :average, :rating 28 | # define_eager_group :approved_comments_count, :comments, :count, :*, -> { approved } 29 | # end 30 | def define_eager_group(attr, association, aggregate_function, column_name, scope = nil) 31 | add_eager_group_definition(self, attr, Definition.new(association, aggregate_function, column_name, scope)) 32 | define_definition_accessor(attr) 33 | end 34 | 35 | def define_definition_accessor(definition_name) 36 | define_method definition_name, 37 | lambda { |*args| 38 | query_result_cache = instance_variable_get("@#{definition_name}") 39 | return query_result_cache if args.blank? && query_result_cache.present? 40 | 41 | preload_eager_group(definition_name, *args) 42 | instance_variable_get("@#{definition_name}") 43 | } 44 | 45 | define_method "#{definition_name}=" do |val| 46 | instance_variable_set("@#{definition_name}", val) 47 | end 48 | end 49 | end 50 | 51 | private 52 | 53 | def preload_eager_group(*eager_group_value) 54 | EagerGroup::Preloader.new(self.class, [self], [eager_group_value]).run 55 | end 56 | end 57 | 58 | require 'active_record' 59 | ActiveRecord::Base.class_eval do 60 | include EagerGroup 61 | class << self 62 | delegate :eager_group, to: :all 63 | end 64 | end 65 | require 'active_record/with_eager_group' 66 | ActiveRecord::Relation.prepend ActiveRecord::WithEagerGroup 67 | -------------------------------------------------------------------------------- /spec/support/models.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User < ActiveRecord::Base 4 | has_many :posts 5 | has_many :comments, through: :posts 6 | 7 | define_eager_group :comments_count, :comments, :count, :* 8 | end 9 | 10 | class Post < ActiveRecord::Base 11 | belongs_to :user 12 | has_many :comments 13 | 14 | define_eager_group :comments_average_rating, :comments, :average, :rating 15 | define_eager_group :approved_comments_count, :comments, :count, :*, -> { approved } 16 | define_eager_group :comments_average_rating_by_author, 17 | :comments, 18 | :average, 19 | :rating, 20 | ->(author, ignore) { by_author(author, ignore) } 21 | define_eager_group :first_comment, :comments, :first_object, :id 22 | define_eager_group :last_comment, :comments, :last_object, :id 23 | end 24 | 25 | class Comment < ActiveRecord::Base 26 | belongs_to :post 27 | belongs_to :author, polymorphic: true 28 | 29 | scope :approved, -> { where(status: 'approved') } 30 | scope :by_author, ->(author, _ignore) { where(author: author) } 31 | end 32 | 33 | class Teacher < ActiveRecord::Base 34 | has_and_belongs_to_many :students 35 | has_many :homeworks 36 | 37 | define_eager_group :students_count, :students, :count, :* 38 | end 39 | 40 | class Student < ActiveRecord::Base 41 | has_and_belongs_to_many :teachers 42 | has_many :comments, as: :author 43 | has_many :posts, through: :comments 44 | has_many :homeworks 45 | 46 | define_eager_group :posts_count, :posts, :count, 'distinct post_id' 47 | end 48 | 49 | class Homework < ActiveRecord::Base 50 | belongs_to :teacher 51 | belongs_to :student 52 | 53 | has_many :comments, through: :student 54 | 55 | define_eager_group :students_count, :students, :count, '*' 56 | define_eager_group :student_comments_count, :comments, :count, '*' 57 | end 58 | 59 | class Vehicle < ActiveRecord::Base 60 | has_many :passengers 61 | has_many :users, through: :passengers 62 | 63 | define_eager_group :passengers_count, :passengers, :count, '*' 64 | end 65 | 66 | class SchoolBus < Vehicle 67 | define_eager_group :credited_passengers_count, :passengers, :count, '*', -> { where('age < 10') } 68 | define_eager_group :young_passengers_count, :passengers, :count, '*', -> { where('age < 8') } 69 | end 70 | 71 | class Sedan < Vehicle 72 | define_eager_group :credited_passengers_count, :passengers, :count, '*', -> { where('age < 8') } 73 | 74 | end 75 | 76 | class Passenger < ActiveRecord::Base 77 | belongs_to :vehicle 78 | 79 | end 80 | 81 | ActiveRecord::Base.logger = Logger.new(STDOUT) 82 | -------------------------------------------------------------------------------- /lib/active_record/with_eager_group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module WithEagerGroup 5 | def exec_queries 6 | records = super 7 | EagerGroup::Preloader.new(klass, records, eager_group_values).run if eager_group_values.present? 8 | records 9 | end 10 | 11 | def eager_group(*args) 12 | # we does not use the `check_if_method_has_arguments!` here because it would flatten all the arguments, 13 | # which would cause `[:eager_group_definition, scope_arg1, scope_arg2]` not able to preload together with other `eager_group_definitions`. 14 | # e.g. `Post.eager_group(:approved_comments_count, [:comments_average_rating_by_author, students[0], true])` 15 | check_argument_not_blank!(args) 16 | check_argument_valid!(args) 17 | 18 | spawn.eager_group!(*args) 19 | end 20 | 21 | def eager_group!(*args) 22 | self.eager_group_values |= args 23 | self 24 | end 25 | 26 | def eager_group_values 27 | @values[:eager_group] || [] 28 | end 29 | 30 | def eager_group_values=(values) 31 | raise ImmutableRelation if @loaded 32 | 33 | @values[:eager_group] = values 34 | end 35 | 36 | private 37 | 38 | def check_argument_not_blank!(args) 39 | raise ArgumentError, "The method .eager_group() must contain arguments." if args.blank? 40 | args.compact_blank! 41 | end 42 | 43 | def check_argument_valid!(args) 44 | args.each do |eager_group_value| 45 | check_eager_group_definitions_exists!(klass, eager_group_value) 46 | end 47 | end 48 | 49 | def check_eager_group_definitions_exists!(klass, eager_group_value) 50 | case eager_group_value 51 | when Symbol, String 52 | raise ArgumentError, "Unknown eager group definition :#{eager_group_value}" unless klass.eager_group_definitions.has_key?(eager_group_value) 53 | when Array 54 | definition_name = eager_group_value.first 55 | raise ArgumentError, "Unknown eager group definition :#{definition_name}" unless klass.eager_group_definitions.has_key?(definition_name) 56 | when Hash 57 | eager_group_value.each do |association_name, association_eager_group_values| 58 | association_klass = klass.reflect_on_association(association_name).klass 59 | 60 | Array.wrap(association_eager_group_values).each do |association_eager_group_value| 61 | check_eager_group_definitions_exists!(association_klass, association_eager_group_value) 62 | end 63 | end 64 | else 65 | raise ArgumentError, "Unknown eager_group argument :#{eager_group_value.inspect}" 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /benchmark.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Calculating ------------------------------------- 4 | # Without EagerGroup 2.000 i/100ms 5 | # With EagerGroup 28.000 i/100ms 6 | # ------------------------------------------------- 7 | # Without EagerGroup 28.883 (± 6.9%) i/s - 144.000 8 | # With EagerGroup 281.755 (± 5.0%) i/s - 1.428k 9 | # 10 | # Comparison: 11 | # With EagerGroup: 281.8 i/s 12 | # Without EagerGroup: 28.9 i/s - 9.76x slower 13 | $: << 'lib' 14 | require 'benchmark/ips' 15 | require 'active_record' 16 | require 'activerecord-import' 17 | require 'eager_group' 18 | 19 | class Post < ActiveRecord::Base 20 | has_many :comments 21 | 22 | define_eager_group :comments_average_rating, :comments, :average, :rating 23 | define_eager_group :approved_comments_count, :comments, :count, :*, -> { approved } 24 | end 25 | 26 | class Comment < ActiveRecord::Base 27 | belongs_to :post 28 | 29 | scope :approved, -> { where(status: 'approved') } 30 | end 31 | 32 | # create database eager_group_benchmark; 33 | ActiveRecord::Base.establish_connection( 34 | adapter: 'mysql2', database: 'eager_group_benchmark', server: '/tmp/mysql.socket', username: 'root' 35 | ) 36 | 37 | ActiveRecord::Base.connection.tables.each { |table| ActiveRecord::Base.connection.drop_table(table) } 38 | 39 | ActiveRecord::Schema.define do 40 | self.verbose = false 41 | 42 | create_table :posts, force: true do |t| 43 | t.string :title 44 | t.string :body 45 | t.timestamps null: false 46 | end 47 | 48 | create_table :comments, force: true do |t| 49 | t.string :body 50 | t.string :status 51 | t.integer :rating 52 | t.integer :post_id 53 | t.timestamps null: false 54 | end 55 | end 56 | 57 | posts_size = 100 58 | comments_size = 1_000 59 | 60 | posts = [] 61 | posts_size.times { |i| posts << Post.new(title: "Title #{i}", body: "Body #{i}") } 62 | Post.import posts 63 | post_ids = Post.all.pluck(:id) 64 | 65 | comments = [] 66 | comments_size.times do |i| 67 | comments << 68 | Comment.new( 69 | body: "Comment #{i}", post_id: post_ids[i % 100], status: %w[approved deleted][i % 2], rating: i % 5 + 1 70 | ) 71 | end 72 | Comment.import comments 73 | 74 | Benchmark.ips do |x| 75 | x.report('Without EagerGroup') do 76 | Post.limit(20).each do |post| 77 | post.comments.approved.count 78 | post.comments.approved.average('rating') 79 | end 80 | end 81 | 82 | x.report('With EagerGroup') do 83 | Post.eager_group(:approved_comments_count, :comments_average_rating).limit(20).each do |post| 84 | post.approved_comments_count 85 | post.comments_average_rating 86 | end 87 | end 88 | 89 | x.compare! 90 | end 91 | -------------------------------------------------------------------------------- /lib/eager_group/preloader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module EagerGroup 4 | class Preloader 5 | autoload :AggregationFinder, 'eager_group/preloader/aggregation_finder' 6 | autoload :HasMany, 'eager_group/preloader/has_many' 7 | autoload :HasManyThroughBelongsTo, 'eager_group/preloader/has_many_through_belongs_to' 8 | autoload :HasManyThroughMany, 'eager_group/preloader/has_many_through_many' 9 | autoload :ManyToMany, 'eager_group/preloader/many_to_many' 10 | 11 | def initialize(klass, records, eager_group_values) 12 | @klass = klass 13 | @records = Array.wrap(records).compact.uniq 14 | @eager_group_values = eager_group_values 15 | end 16 | 17 | # Preload aggregate functions 18 | def run 19 | @eager_group_values.each do |eager_group_value| 20 | definition_key, arguments = 21 | eager_group_value.is_a?(Array) ? [eager_group_value.shift, eager_group_value] : [eager_group_value, nil] 22 | 23 | if definition_key.is_a?(Hash) 24 | association_name, definition_key = *definition_key.first 25 | next if @records.empty? 26 | @klass = @records.first.class.reflect_on_association(association_name).klass 27 | 28 | @records = @records.flat_map { |record| record.send(association_name) } 29 | next if @records.empty? 30 | 31 | 32 | self.class.new(@klass, @records, Array.wrap(definition_key)).run 33 | end 34 | 35 | find_aggregate_values_per_definition!(definition_key, arguments) 36 | end 37 | end 38 | 39 | def find_aggregate_values_per_definition!(definition_key, arguments) 40 | unless definition = @klass.eager_group_definitions[definition_key] 41 | return 42 | end 43 | 44 | reflection = @klass.reflect_on_association(definition.association) 45 | return if reflection.blank? 46 | 47 | aggregation_finder_class = if reflection.is_a?(ActiveRecord::Reflection::HasAndBelongsToManyReflection) 48 | ManyToMany 49 | elsif reflection.through_reflection 50 | if reflection.through_reflection.is_a?(ActiveRecord::Reflection::BelongsToReflection) 51 | HasManyThroughBelongsTo 52 | else 53 | HasManyThroughMany 54 | end 55 | else 56 | HasMany 57 | end 58 | 59 | aggregation_finder = aggregation_finder_class.new(@klass, definition, arguments, @records) 60 | aggregate_hash = aggregation_finder.aggregate_hash 61 | 62 | if definition.need_load_object 63 | aggregate_objects = reflection.klass.find(aggregate_hash.values).each_with_object({}) { |o, h| h[o.id] = o } 64 | aggregate_hash.keys.each { |key| aggregate_hash[key] = aggregate_objects[aggregate_hash[key]] } 65 | end 66 | 67 | @records.each do |record| 68 | id = record.send(aggregation_finder.group_by_key) 69 | record.send("#{definition_key}=", aggregate_hash[id] || definition.default_value) 70 | end 71 | end 72 | 73 | private 74 | 75 | def polymophic_as_condition(reflection) 76 | reflection.type ? { reflection.name => { reflection.type => @klass.base_class.name } } : [] 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by the `rspec --init` command. Conventionally, all 4 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 5 | # The generated `.rspec` file contains `--require spec_helper` which will cause 6 | # this file to always be loaded, without a need to explicitly require it in any 7 | # files. 8 | # 9 | # Given that it is always loaded, you are encouraged to keep this file as 10 | # light-weight as possible. Requiring heavyweight dependencies from this file 11 | # will add to the boot time of your test suite on EVERY test run, even for an 12 | # individual file that may not need all of that loaded. Instead, consider making 13 | # a separate helper file that requires the additional dependencies and performs 14 | # the additional setup, and require it from the spec files that actually need 15 | # it. 16 | # 17 | # The `.rspec` file also contains a few flags that are not defaults but that 18 | # users commonly want. 19 | # 20 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 21 | 22 | require 'eager_group' 23 | require 'pry' 24 | 25 | load 'support/schema.rb' 26 | load 'support/models.rb' 27 | load 'support/data.rb' 28 | 29 | 30 | RSpec.configure do |config| 31 | # rspec-expectations config goes here. You can use an alternate 32 | # assertion/expectation library such as wrong or the stdlib/minitest 33 | # assertions if you prefer. 34 | config.expect_with :rspec do |expectations| 35 | # This option will default to `true` in RSpec 4. It makes the `description` 36 | # and `failure_message` of custom matchers include text for helper methods 37 | # defined using `chain`, e.g.: 38 | # be_bigger_than(2).and_smaller_than(4).description 39 | # # => "be bigger than 2 and smaller than 4" 40 | # ...rather than: 41 | # # => "be bigger than 2" 42 | expectations.include_chain_clauses_in_custom_matcher_descriptions = 43 | true 44 | end 45 | 46 | # rspec-mocks config goes here. You can use an alternate test double 47 | # library (such as bogus or mocha) by changing the `mock_with` option here. 48 | config.mock_with :rspec do |mocks| 49 | # Prevents you from mocking or stubbing a method that does not exist on 50 | # a real object. This is generally recommended, and will default to 51 | # `true` in RSpec 4. 52 | mocks.verify_partial_doubles = 53 | true 54 | end 55 | 56 | # The settings below are suggested to provide a good initial experience 57 | # with RSpec, but feel free to customize to your heart's content. 58 | =begin 59 | # These two settings work together to allow you to limit a spec run 60 | # to individual examples or groups you care about by tagging them with 61 | # `:focus` metadata. When nothing is tagged with `:focus`, all examples 62 | # get run. 63 | config.filter_run :focus 64 | config.run_all_when_everything_filtered = true 65 | 66 | # Allows RSpec to persist some state between runs in order to support 67 | # the `--only-failures` and `--next-failure` CLI options. We recommend 68 | # you configure your source control system to ignore this file. 69 | config.example_status_persistence_file_path = "spec/examples.txt" 70 | 71 | # Limits the available syntax to the non-monkey patched syntax that is 72 | # recommended. For more details, see: 73 | # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax 74 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 75 | # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching 76 | config.disable_monkey_patching! 77 | 78 | # This setting enables warnings. It's recommended, but in some cases may 79 | # be too noisy due to issues in dependencies. 80 | config.warnings = true 81 | 82 | # Many RSpec users commonly either run the entire suite or an individual 83 | # file, and it's useful to allow more verbose output when running an 84 | # individual spec file. 85 | if config.files_to_run.one? 86 | # Use the documentation formatter for detailed output, 87 | # unless a formatter has already been configured 88 | # (e.g. via a command-line flag). 89 | config.default_formatter = 'doc' 90 | end 91 | 92 | # Print the 10 slowest examples and example groups at the 93 | # end of the spec run, to help surface which specs are running 94 | # particularly slow. 95 | config.profile_examples = 10 96 | 97 | # Run specs in random order to surface order dependencies. If you find an 98 | # order dependency and want to debug it, you can fix the order by providing 99 | # the seed, which is printed after each run. 100 | # --seed 1234 101 | config.order = :random 102 | 103 | # Seed global randomization in this process using the `--seed` CLI option. 104 | # Setting this allows you to use `--seed` to deterministically reproduce 105 | # test failures related to randomization by passing the same `--seed` value 106 | # as the one that triggered the failure. 107 | Kernel.srand config.seed 108 | =end 109 | end 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EagerGroup 2 | 3 | [![Build Status](https://secure.travis-ci.org/flyerhzm/eager_group.png)](http://travis-ci.org/flyerhzm/eager_group) 4 | [![AwesomeCode Status for 5 | flyerhzm/eager_group](https://awesomecode.io/projects/e5386790-9420-4003-831a-c9a8c8a48108/status)](https://awesomecode.io/repos/flyerhzm/eager_group) 6 | 7 | [More explaination on our blog](http://blog.flyerhzm.com/2015/06/29/eager_group/) 8 | 9 | Fix n+1 aggregate sql functions for rails, like 10 | 11 | ``` 12 | SELECT "posts".* FROM "posts"; 13 | SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1 AND "comments"."status" = 'approved' 14 | SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 2 AND "comments"."status" = 'approved' 15 | SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 3 AND "comments"."status" = 'approved' 16 | ``` 17 | 18 | => 19 | 20 | ``` 21 | SELECT "posts".* FROM "posts"; 22 | SELECT COUNT(*) AS count_all, post_id AS post_id FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3) AND "comments"."status" = 'approved' GROUP BY post_id; 23 | ``` 24 | 25 | or 26 | 27 | ``` 28 | SELECT "posts".* FROM "posts"; 29 | SELECT AVG("comments"."rating") AS avg_id FROM "comments" WHERE "comments"."post_id" = 1; 30 | SELECT AVG("comments"."rating") AS avg_id FROM "comments" WHERE "comments"."post_id" = 2; 31 | SELECT AVG("comments"."rating") AS avg_id FROM "comments" WHERE "comments"."post_id" = 3; 32 | ``` 33 | 34 | => 35 | 36 | ``` 37 | SELECT "posts".* FROM "posts"; 38 | SELECT AVG("comments"."rating") AS average_comments_rating, post_id AS post_id FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3) GROUP BY post_id; 39 | ``` 40 | 41 | It supports Rails 4.x, Rails 5.x and Rails 6.x 42 | 43 | ## Installation 44 | 45 | Add this line to your application's Gemfile: 46 | 47 | ```ruby 48 | gem 'eager_group' 49 | ``` 50 | 51 | And then execute: 52 | 53 | ``` 54 | $ bundle 55 | ``` 56 | 57 | Or install it yourself as: 58 | 59 | ``` 60 | $ gem install eager_group 61 | ``` 62 | 63 | ## Usage 64 | 65 | First you need to define what aggregate function you want to eager 66 | load. 67 | 68 | ```ruby 69 | class Post < ActiveRecord::Base 70 | has_many :comments 71 | 72 | define_eager_group :comments_average_rating, :comments, :average, :rating 73 | define_eager_group :approved_comments_count, :comments, :count, :*, -> { approved } 74 | end 75 | 76 | class Comment < ActiveRecord::Base 77 | belongs_to :post 78 | 79 | scope :approved, -> { where(status: 'approved') } 80 | end 81 | ``` 82 | 83 | The parameters for `define_eager_group` are as follows 84 | 85 | * `definition_name`, it's used to be a reference in `eager_group` query 86 | method, it also generates a method with the same name to fetch the 87 | result. 88 | * `association`, association name you want to aggregate. 89 | * `aggregate_function`, aggregate sql function, can be one of `average`, 90 | `count`, `maximum`, `minimum`, `sum`, I define 2 additional aggregate 91 | function `first_object` and `last_object` to eager load first and last 92 | association objects. 93 | * `column_name`, aggregate column name, it can be `:*` for `count` 94 | * `scope`, scope is optional, it's used to filter data for aggregation. 95 | 96 | Then you can use `eager_group` to fix n+1 aggregate sql functions 97 | when querying 98 | 99 | ```ruby 100 | posts = Post.all.eager_group(:comments_average_rating, :approved_comments_count) 101 | posts.each do |post| 102 | post.comments_average_rating 103 | post.approved_comments_count 104 | end 105 | ``` 106 | 107 | EagerGroup will execute `GROUP BY` sqls for you then set the value of 108 | attributes. 109 | 110 | `define_eager_group` will define a method in model. 111 | You can call the `definition_name` directly for convenience, 112 | but it would not help you to fix n+1 aggregate sql issue. 113 | 114 | ``` 115 | post = Post.first 116 | post.commets_average_rating 117 | post.approved_comments_count 118 | ``` 119 | 120 | ## Advanced 121 | 122 | `eager_group` through association 123 | 124 | ```ruby 125 | User.limit(10).includes(:posts).eager_group(posts: [:comments_average_rating, :approved_comments_count]) 126 | ``` 127 | 128 | pass parameter to scope 129 | 130 | ```ruby 131 | class Post < ActiveRecord::Base 132 | has_many :comments 133 | 134 | define_eager_group :comments_average_rating_by_author, :comments, :average, :rating, ->(author, ignore) { by_author(author, ignore) } 135 | end 136 | 137 | posts = Post.all.eager_group([:comments_average_rating_by_author, author, true]) 138 | posts.each { |post| post.comments_average_rating_by_author } 139 | ``` 140 | 141 | `first_object` and `last_object` aggregation to eager load first and 142 | last association objects. 143 | 144 | ```ruby 145 | class Post < ActiveRecord::Base 146 | has_many :comments 147 | 148 | define_eager_group :first_comment, :comments, :first_object, :id 149 | define_eager_group :last_comment, :comments, :last_object, :id 150 | end 151 | 152 | posts = Post.all.eager_group(:first_comment, :last_comment) 153 | posts.each do |post| 154 | post.first_comment 155 | post.last_comment 156 | end 157 | ``` 158 | 159 | 160 | ## Benchmark 161 | 162 | I wrote a benchmark script [here][1], it queries approved comments count 163 | and comments average rating for 20 posts, with eager group, it gets 10 164 | times faster, WOW! 165 | 166 | ## Contributing 167 | 168 | Bug reports and pull requests are welcome on GitHub at https://github.com/flyerhzm/eager_group. 169 | 170 | [1]: https://github.com/flyerhzm/eager_group/blob/master/benchmark.rb 171 | -------------------------------------------------------------------------------- /spec/integration/eager_group_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe EagerGroup, type: :model do 6 | describe '.eager_group' do 7 | context 'has_many' do 8 | it 'gets Post#approved_comments_count' do 9 | posts = Post.eager_group(:approved_comments_count) 10 | expect(posts[0].approved_comments_count).to eq 1 11 | expect(posts[1].approved_comments_count).to eq 2 12 | expect(posts[2].approved_comments_count).to eq 0 13 | end 14 | 15 | it 'gets Post#comments_average_rating' do 16 | posts = Post.eager_group(:comments_average_rating) 17 | expect(posts[0].comments_average_rating).to eq 3 18 | expect(posts[1].comments_average_rating).to eq 4 19 | expect(posts[2].comments_average_rating).to eq 0 20 | end 21 | 22 | it 'gets both Post#approved_comments_count and Post#comments_average_rating' do 23 | posts = Post.eager_group(:approved_comments_count, :comments_average_rating) 24 | expect(posts[0].approved_comments_count).to eq 1 25 | expect(posts[0].comments_average_rating).to eq 3 26 | expect(posts[1].approved_comments_count).to eq 2 27 | expect(posts[1].comments_average_rating).to eq 4 28 | expect(posts[2].approved_comments_count).to eq 0 29 | expect(posts[2].comments_average_rating).to eq 0 30 | end 31 | 32 | it 'gets Post#comments_average_rating_by_author' do 33 | students = Student.all 34 | posts = Post.eager_group([:comments_average_rating_by_author, students[0], true]) 35 | expect(posts[0].comments_average_rating_by_author).to eq 4.5 36 | expect(posts[1].comments_average_rating_by_author).to eq 3 37 | end 38 | 39 | it 'eager_group multiple different type of aggregate definitions' do 40 | students = Student.all 41 | posts = Post.eager_group(:approved_comments_count, [:comments_average_rating_by_author, students[0], true]) 42 | #comments_average_rating_by_author 43 | expect(posts[0].comments_average_rating_by_author).to eq 4.5 44 | expect(posts[1].comments_average_rating_by_author).to eq 3 45 | # approved_comments_count 46 | expect(posts[0].approved_comments_count).to eq 1 47 | expect(posts[1].approved_comments_count).to eq 2 48 | expect(posts[2].approved_comments_count).to eq 0 49 | end 50 | 51 | it 'gets Post#comments_average_rating from users' do 52 | users = User.includes(:posts).eager_group(posts: :comments_average_rating) 53 | expect(users[0].posts[0].comments_average_rating).to eq 3 54 | expect(users[1].posts[0].comments_average_rating).to eq 4 55 | end 56 | 57 | it 'gets Post#comments_average_rating from users' do 58 | users = User.includes(:posts).eager_group(posts: :comments_average_rating) 59 | expect(users[0].posts[0].comments_average_rating).to eq 3 60 | expect(users[1].posts[0].comments_average_rating).to eq 4 61 | end 62 | 63 | it 'gets Post#first_comment and Post#last_comment' do 64 | posts = Post.eager_group(:first_comment, :last_comment) 65 | expect(posts[0].first_comment).to eq posts[0].comments.first 66 | expect(posts[1].first_comment).to eq posts[1].comments.first 67 | expect(posts[2].first_comment).to be_nil 68 | expect(posts[0].last_comment).to eq posts[0].comments.last 69 | expect(posts[1].last_comment).to eq posts[1].comments.last 70 | expect(posts[2].last_comment).to be_nil 71 | end 72 | 73 | it 'gets Post#comments_average_rating and Post#comments_average_rating from users' do 74 | users = User.includes(:posts).eager_group(posts: %i[approved_comments_count comments_average_rating]) 75 | 76 | expect(users[0].posts[0].approved_comments_count).to eq 1 77 | expect(users[0].posts[0].comments_average_rating).to eq 3 78 | expect(users[1].posts[0].approved_comments_count).to eq 2 79 | expect(users[1].posts[0].comments_average_rating).to eq 4 80 | end 81 | 82 | it 'does not raise error when association is empty' do 83 | Teacher.includes(:homeworks).eager_group(homeworks: :students_count).to_a 84 | end 85 | end 86 | 87 | context 'has_and_belongs_to_many' do 88 | it 'gets Teacher#students_count' do 89 | teachers = Teacher.eager_group(:students_count) 90 | expect(teachers[0].students_count).to eq 1 91 | expect(teachers[1].students_count).to eq 3 92 | expect(teachers[2].students_count).to eq 0 93 | end 94 | end 95 | 96 | context 'has_many :through many' do 97 | it 'gets Student#posts_count' do 98 | students = Student.eager_group(:posts_count) 99 | expect(students[0].posts_count).to eq 2 100 | expect(students[1].posts_count).to eq 1 101 | expect(students[2].posts_count).to eq 0 102 | end 103 | 104 | it 'gets User#comments_count' do 105 | users = User.eager_group(:comments_count) 106 | expect(users[0].comments_count).to eq 3 107 | expect(users[1].comments_count).to eq 2 108 | end 109 | end 110 | 111 | context 'has_many :through belongs to' do 112 | it 'gets Homework#student_comments_count' do 113 | homeworks = Homework.eager_group(:student_comments_count) 114 | expect(homeworks[0].student_comments_count).to eq(3) 115 | expect(homeworks[1].student_comments_count).to eq(1) 116 | end 117 | end 118 | 119 | context 'support STI' do 120 | it 'gets SchoolBus#credited_passengers_count' do 121 | buses = SchoolBus.eager_group(:credited_passengers_count, :passengers_count) 122 | expect(buses[0].credited_passengers_count).to eq(3) 123 | expect(buses[0].passengers_count).to eq(4) 124 | end 125 | 126 | it 'gets Sedan#credited_passengers_count' do 127 | sedans = Sedan.eager_group(:credited_passengers_count, :passengers_count) 128 | expect(sedans[0].credited_passengers_count).to eq(1) 129 | expect(sedans[0].passengers_count).to eq(4) 130 | end 131 | 132 | it 'gets Vehicle#passengers_count' do 133 | vehicles = Vehicle.eager_group(:passengers_count) 134 | expect(vehicles[0].passengers_count).to eq(4) 135 | expect(vehicles[1].passengers_count).to eq(4) 136 | end 137 | end 138 | 139 | context 'check arguments' do 140 | context 'definition not exists' do 141 | it 'should raise ArgumentError' do 142 | expect{ Sedan.eager_group(:young_passengers_count) }.to raise_error(ArgumentError) 143 | end 144 | 145 | it 'should raise ArgumentError from association' do 146 | expect{ User.includes(:posts).eager_group(posts: %i[unknown_eager_group_definition]) }.to raise_error(ArgumentError) 147 | end 148 | 149 | it "should raise ArgumentError when parent class call a non-exist definition" do 150 | expect { Vehicle.eager_group(:credited_passengers_count) }.to raise_error(ArgumentError) 151 | end 152 | end 153 | end 154 | end 155 | 156 | describe '.preload_eager_group' do 157 | context 'Cache query result' do 158 | it 'eager_group result cached' do 159 | posts = Post.eager_group(:approved_comments_count) 160 | post = posts[0] 161 | object_id1 = post.instance_variable_get('@approved_comments_count').object_id 162 | object_id2 = post.approved_comments_count.object_id 163 | object_id3 = post.approved_comments_count.object_id 164 | expect(object_id1).to eq object_id2 165 | expect(object_id1).to eq object_id3 166 | end 167 | 168 | it 'eager_group result cached if arguments given' do 169 | students = Student.all 170 | posts = Post.eager_group([:comments_average_rating_by_author, students[0], true]) 171 | post = posts[0] 172 | object_id1 = post.instance_variable_get('@comments_average_rating_by_author').object_id 173 | object_id2 = post.comments_average_rating_by_author.object_id 174 | object_id3 = post.comments_average_rating_by_author.object_id 175 | expect(object_id1).to eq object_id2 176 | expect(object_id1).to eq object_id3 177 | end 178 | 179 | it 'magic method result cached' do 180 | post = Post.first 181 | object_id1 = post.approved_comments_count.object_id 182 | object_id2 = post.approved_comments_count.object_id 183 | expect(object_id1).to eq object_id2 184 | end 185 | 186 | it 'magic method not cache if arguments given' do 187 | students = Student.all 188 | posts = Post.all 189 | object_id1 = posts[0].comments_average_rating_by_author(students[0], true).object_id 190 | object_id2 = posts[0].comments_average_rating_by_author(students[0], true).object_id 191 | expect(object_id1).not_to eq object_id2 192 | end 193 | end 194 | 195 | context 'has_many' do 196 | it 'gets Post#approved_comments_count' do 197 | posts = Post.all 198 | expect(posts[0].approved_comments_count).to eq 1 199 | expect(posts[1].approved_comments_count).to eq 2 200 | end 201 | 202 | it 'gets Post#comments_average_rating' do 203 | posts = Post.all 204 | expect(posts[0].comments_average_rating).to eq 3 205 | expect(posts[1].comments_average_rating).to eq 4 206 | end 207 | 208 | it 'gets both Post#approved_comments_count and Post#comments_average_rating' do 209 | posts = Post.all 210 | expect(posts[0].approved_comments_count).to eq 1 211 | expect(posts[0].comments_average_rating).to eq 3 212 | expect(posts[1].approved_comments_count).to eq 2 213 | expect(posts[1].comments_average_rating).to eq 4 214 | expect(posts[2].approved_comments_count).to eq 0 215 | end 216 | 217 | it 'gets Post#comments_average_rating_by_author' do 218 | students = Student.all 219 | posts = Post.all 220 | expect(posts[0].comments_average_rating_by_author(students[0], true)).to eq 4.5 221 | expect(posts[1].comments_average_rating_by_author(students[0], true)).to eq 3 222 | end 223 | end 224 | 225 | context 'has_and_belongs_to_many' do 226 | it 'gets Teacher#students_count' do 227 | teachers = Teacher.all 228 | expect(teachers[0].students_count).to eq 1 229 | expect(teachers[1].students_count).to eq 3 230 | expect(teachers[2].students_count).to eq 0 231 | end 232 | end 233 | 234 | context 'has_many :as, has_many :through' do 235 | it 'gets Student#posts_count' do 236 | students = Student.all 237 | expect(students[0].posts_count).to eq 2 238 | expect(students[1].posts_count).to eq 1 239 | expect(students[2].posts_count).to eq 0 240 | end 241 | 242 | end 243 | end 244 | end 245 | --------------------------------------------------------------------------------