├── .gitignore ├── .rspec ├── .travis.yml ├── Appraisals ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── batch-loader-active-record.gemspec ├── bin ├── console └── setup ├── gemfiles ├── .bundle │ └── config ├── activerecord_4_2.gemfile ├── activerecord_4_2.gemfile.lock ├── activerecord_5_0.gemfile ├── activerecord_5_0.gemfile.lock ├── activerecord_5_1.gemfile ├── activerecord_5_1.gemfile.lock ├── activerecord_5_2.gemfile └── activerecord_5_2.gemfile.lock ├── lib ├── batch-loader-active-record.rb ├── batch_loader_active_record.rb └── batch_loader_active_record │ ├── association_manager.rb │ └── version.rb └── spec ├── belongs_to_spec.rb ├── has_and_belongs_to_many_spec.rb ├── has_many_spec.rb ├── has_one_spec.rb ├── polymorphic_spec.rb ├── spec_helper.rb └── support └── active_record_helpers.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | .byebug_history 11 | 12 | # rspec failure tracking 13 | .rspec_status 14 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | --order rand 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | rvm: 4 | - 2.3.3 5 | 6 | sudo: false 7 | 8 | before_install: gem install bundler -v 1.16.0 9 | 10 | gemfile: 11 | - gemfiles/activerecord_4_2.gemfile 12 | - gemfiles/activerecord_5_0.gemfile 13 | - gemfiles/activerecord_5_1.gemfile 14 | - gemfiles/activerecord_5_2.gemfile -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "activerecord-4-2" do 2 | gem "activerecord", "4.2.10" 3 | end 4 | 5 | appraise "activerecord-5-0" do 6 | gem "activerecord", "5.0.6" 7 | end 8 | 9 | appraise "activerecord-5-1" do 10 | gem "activerecord", "5.1.4" 11 | end 12 | 13 | appraise "activerecord-5-2" do 14 | gem "activerecord", "5.2.0.beta1" 15 | end 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Unreleased 4 | 5 | * none 6 | 7 | v0.5.0 8 | 9 | * support polymorphic associations 10 | 11 | v0.4.3 12 | 13 | * fix dependency to activerecord and builds 14 | 15 | v0.4.2 16 | 17 | * fix to support activerecord v4.2.10 and test v4.2, v5.0, v5.1 and v5.2 using appraisal 18 | 19 | v0.4.1 20 | 21 | * fix cache issue when calling lazy association accessor with different scopes for `has_and_belongs_to` associations 22 | 23 | v0.4.0 24 | 25 | * support `has_and_belongs_to` associations 26 | 27 | v0.3.1 28 | 29 | * allow to decouple declaring the assocation with Active Record DSL and generate a lazy association accessor with `association_accessor` 30 | 31 | v0.3.0 32 | 33 | * allow to specify an association scope with `belongs_to_lazy`, `has_one_lazy` and `has_many_lazy` 34 | 35 | v0.2.0 36 | 37 | * allow to use `has_many_lazy` with `through: ...` option 38 | 39 | v0.1.0 40 | 41 | * initial release 42 | * doens't support `has_and_belongs_to_lazy` 43 | * doesn't support `has_many_lazy ... through: ...` 44 | * doesn't support association scope 45 | * doesn't support polymorphic associations -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at mathieu@caring.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in batch_loader_active_record.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | batch-loader-active-record (0.5.0) 5 | activerecord (>= 4.2.0, < 5.3.0) 6 | batch-loader (~> 1.2.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | activemodel (5.1.4) 12 | activesupport (= 5.1.4) 13 | activerecord (5.1.4) 14 | activemodel (= 5.1.4) 15 | activesupport (= 5.1.4) 16 | arel (~> 8.0) 17 | activesupport (5.1.4) 18 | concurrent-ruby (~> 1.0, >= 1.0.2) 19 | i18n (~> 0.7) 20 | minitest (~> 5.1) 21 | tzinfo (~> 1.1) 22 | appraisal (2.2.0) 23 | bundler 24 | rake 25 | thor (>= 0.14.0) 26 | arel (8.0.0) 27 | batch-loader (1.2.0) 28 | byebug (9.1.0) 29 | coderay (1.1.2) 30 | concurrent-ruby (1.0.5) 31 | diff-lcs (1.3) 32 | i18n (0.9.1) 33 | concurrent-ruby (~> 1.0) 34 | method_source (0.9.0) 35 | minitest (5.10.3) 36 | pry (0.11.3) 37 | coderay (~> 1.1.0) 38 | method_source (~> 0.9.0) 39 | pry-byebug (3.5.1) 40 | byebug (~> 9.1) 41 | pry (~> 0.10) 42 | rake (10.5.0) 43 | rspec (3.7.0) 44 | rspec-core (~> 3.7.0) 45 | rspec-expectations (~> 3.7.0) 46 | rspec-mocks (~> 3.7.0) 47 | rspec-core (3.7.0) 48 | rspec-support (~> 3.7.0) 49 | rspec-expectations (3.7.0) 50 | diff-lcs (>= 1.2.0, < 2.0) 51 | rspec-support (~> 3.7.0) 52 | rspec-mocks (3.7.0) 53 | diff-lcs (>= 1.2.0, < 2.0) 54 | rspec-support (~> 3.7.0) 55 | rspec-support (3.7.0) 56 | sqlite3 (1.3.13) 57 | thor (0.20.0) 58 | thread_safe (0.3.6) 59 | tzinfo (1.2.4) 60 | thread_safe (~> 0.1) 61 | 62 | PLATFORMS 63 | ruby 64 | 65 | DEPENDENCIES 66 | activesupport (>= 4.2.0, < 5.2.0) 67 | appraisal (~> 2.2.0) 68 | batch-loader-active-record! 69 | bundler (~> 1.16) 70 | pry-byebug (~> 3.5) 71 | rake (~> 10.0) 72 | rspec (~> 3.0) 73 | sqlite3 (~> 1.3.13) 74 | 75 | BUNDLED WITH 76 | 1.16.0 77 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Mathieu Lajugie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Batch Loader - Active Record # 2 | 3 | [![Build Status](https://travis-ci.org/mathieul/batch-loader-active-record.svg?branch=master)](https://travis-ci.org/mathieul/batch-loader-active-record) 4 | [![Gem Version](https://badge.fury.io/rb/batch-loader-active-record.svg)](https://badge.fury.io/rb/batch-loader-active-record) 5 | 6 | This gem allows to leverage the awesome [batch-loader gem](https://github.com/exAspArk/batch-loader) to generate lazy Active Record relationships without any boilerplate. 7 | 8 | It is not intended to be used for all associations though, but only where necessary. It should be used as a complement to vanilla batch loaders written directly using [batch-loader gem](https://github.com/exAspArk/batch-loader). 9 | 10 | **This gem is in active deployment and is likely not yet ready to be used on production.** 11 | 12 | 13 | ## Description 14 | 15 | This gem has a very simple implementation and delegates all batch loading responsibilities (used to avoid N+1 calls to the database) to the [batch-loader gem](https://github.com/exAspArk/batch-loader). It allows to generate a lazy association accessor with a simple statement: `association_accessor :association_name`. 16 | 17 | Refer to the [CHANGELOG](https://github.com/mathieul/batch-loader-active-record/blob/master/CHANGELOG.md) to know what is supported and what is not. 18 | 19 | It is also possible to use one of the macros below in replacement of the original Active Record macro to both declare the association and trigger a lazy association accessort in a single statement. 20 | 21 | * `belongs_to_lazy` 22 | * `has_one_lazy` 23 | * `has_many_lazy` 24 | * `has_and_belongs_to_many_lazy` 25 | 26 | As soon as your lazy association accessor needs to do more than fetch all records of an association (using a scope or not), you're going to want to directly use the batch-loader gem. For more details on N+1 queries read the [batch-loader gem README](https://github.com/exAspArk/batch-loader/#why). 27 | 28 | For example let's imagine a post which can have many comments: 29 | 30 | ```ruby 31 | class Post < ActiveRecord::Base 32 | include BatchLoaderActiveRecord 33 | has_many :comments 34 | association_accessor :comments 35 | end 36 | 37 | class Comment < ActiveRecord::Base 38 | belongs_to :post 39 | end 40 | ``` 41 | 42 | Now we get a list of post objects and we want to fetch all the comments for each post. When we know in advance that we'll need the post comments, then Active Record query [#includes](http://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-includes) will trigger a single query to fetch posts and comments. 43 | 44 | But often we don't know in advance in the code responsible to fetch the posts if we'll need access to the comments as well. When implenting a GraphQL API for instance, the post resolver doesn't know if the comments are also part of the GraphQL query.Using `#includes` in this case would be wasteful and slower for the cases when we don't need the comments. 45 | 46 | When using the lazy association accessor (i.e.: `post.comments_lazy`), a Batch Loader object is returned instead of a model relation and the query with the post id is buffered temporarily in the thread hash. No query to the database is executed yet. Calling the same association accessor on another post instance will add this post id to the list in the tread context. And so on until we access one of those Batch Loader objects returned. Only then is the database query executed and all Batch Loader objects are replaced by the records just fetched (not really replaced, they use delegation under the cover). 47 | 48 | It is important to note that Active Record association accessors return relations which can be chained using the Active Record query API. But the lazy association accessors generated by `batch-loader-active-record` return (for all intents and purposes) an active record instance or an array of active record instances which can't be chained. 49 | 50 | To benefit from the query batching we must first collect the lazy associations for each model instance in our collection, and only then we can start using them to access their content. Accessing a lazy object too early triggers the database query too early. For instance using `#flat_map` to collect and use the lazy objects would fail as `#flat_map` does access each element of the collection immediately in order to flatten the result. 51 | 52 | 53 | ## Installation 54 | 55 | Add this line to your application's Gemfile: 56 | 57 | ```ruby 58 | gem 'batch-loader-active-record' 59 | ``` 60 | 61 | And then execute: 62 | 63 | $ bundle 64 | 65 | Or install it yourself as: 66 | 67 | $ gem install batch-loader-active-record 68 | 69 | Note that this gem supports [active record gem](https://rubygems.org/gems/activerecord) version 4.2.10 and above. 70 | 71 | 72 | ## Usage 73 | 74 | Include the `BatchLoaderActiveRecord` module at the beginning of the model classes where lazy associations are needed, and use one of the lazy class macros to declare all lazy associations. 75 | 76 | ### Belongs To ### 77 | 78 | Consider the following data model: 79 | 80 | ```ruby 81 | class Post < ActiveRecord::Base 82 | has_many :comments 83 | end 84 | 85 | class Comment < ActiveRecord::Base 86 | include BatchLoaderActiveRecord 87 | belongs_to_lazy :post 88 | end 89 | ``` 90 | 91 | We need to know the `post` owning each instance of `comments`: 92 | 93 | ```ruby 94 | posts = comments.map(&:post_lazy) 95 | # no DB query executed yet 96 | posts.map(&:author_first_name) 97 | # DB query was executed 98 | # => ["Jane", "Anne", ...] 99 | ``` 100 | 101 | ### Has One ### 102 | 103 | Consider the following data model: 104 | 105 | ```ruby 106 | class Account < ActiveRecord::Base 107 | include BatchLoaderActiveRecord 108 | has_one_lazy :affiliate 109 | end 110 | 111 | class Affiliate < ActiveRecord::Base 112 | belongs_to :account 113 | end 114 | ``` 115 | 116 | Fetching all affiliates for the accounts who do have one affiliate: 117 | 118 | ```ruby 119 | affiliates = accounts.map(&:affiliate_lazy) 120 | # no DB query executed yet 121 | affiliates.first.name 122 | # DB query was executed 123 | affiliates.compact 124 | # => [#, #] 125 | ``` 126 | 127 | ### Has Many ### 128 | 129 | Consider the following data model: 130 | 131 | ```ruby 132 | class Contact < ActiveRecord::Base 133 | include BatchLoaderActiveRecord 134 | has_many_lazy :phone_numbers 135 | end 136 | 137 | class PhoneNumber < ActiveRecord::Base 138 | belongs_to :contact 139 | scope :enabled, -> { where(enabled: true) } 140 | end 141 | ``` 142 | 143 | This time we want the list of phone numbers for a collection of contacts. 144 | 145 | ```ruby 146 | contacts.map(&:phone_numbers_lazy).flatten 147 | ``` 148 | 149 | It is also possible to apply scopes and conditions to a lazy has_many association. For instance if we want to only fetch enabled phone numbers in the example above, you would specify the scope like so: 150 | 151 | ```ruby 152 | contacts.map { |contact| contact.phone_numbers_lazy(PhoneNumber.enabled) }.flatten 153 | ``` 154 | 155 | 156 | ### Has Many :through ### 157 | 158 | Consider the following data model with a has-many association going through another has-many-through association. Agents can have many phones they use to call providers: 159 | 160 | ```ruby 161 | class Agent < ActiveRecord::Base 162 | include BatchLoaderActiveRecord 163 | has_many :phones 164 | has_many_lazy :providers, through: :phones 165 | end 166 | 167 | class Phone < ActiveRecord::Base 168 | belongs_to :agent 169 | has_many :calls 170 | has_many :providers, through: :calls 171 | end 172 | 173 | class Call < ActiveRecord::Base 174 | belongs_to :provider 175 | belongs_to :phone 176 | end 177 | 178 | class Provider < ActiveRecord::Base 179 | has_many :calls 180 | end 181 | ``` 182 | 183 | We want to fetch the list of providers who were called by a list of agents: 184 | 185 | ```ruby 186 | agents.map(&:providers_lazy).uniq 187 | ``` 188 | 189 | This would trigger this query for the collection of agents with ids 4212, 265 and 2309: 190 | 191 | ```sql 192 | SELECT providers.*, agents.ID AS _instance_id 193 | FROM providers 194 | INNER JOIN calls ON calls.provider_id = providers.ID 195 | INNER JOIN phones ON phones.ID = calls.phone_id 196 | INNER JOIN agents ON agents.ID = phones.agent_id 197 | WHERE (agents. ID IN(4212, 265, 2309)) 198 | ``` 199 | 200 | ### Has And Belongs To Many ### 201 | 202 | Consider the following data model: 203 | 204 | ```ruby 205 | class User < ActiveRecord::Base 206 | include BatchLoaderActiveRecord 207 | has_and_belongs_to_many :roles 208 | association_accessor :roles 209 | end 210 | 211 | class Role < ActiveRecord::Base 212 | has_and_belongs_to_many :users 213 | end 214 | ``` 215 | 216 | This time we want the list of roles for a collection of users. 217 | 218 | ```ruby 219 | users.map(&:roles_lazy).flatten 220 | ``` 221 | 222 | 223 | ## Development 224 | 225 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 226 | 227 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 228 | 229 | Use [appraisal](https://github.com/thoughtbot/appraisal) to select the version of activerecord to run with. i.e.: run tests with activerecord v5.1 with: 230 | 231 | ```shell 232 | bundle exec appraisal activerecord-5-1 rspec 233 | ``` 234 | 235 | Or run the specs for all supported versions at once: 236 | 237 | ```shell 238 | bundle exec appraisal rspec 239 | ``` 240 | 241 | ## Contributing 242 | 243 | Bug reports and pull requests are welcome on GitHub at https://github.com/mathieul/batch-loader-active-record. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 244 | 245 | ## License 246 | 247 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 248 | 249 | ## Code of Conduct 250 | 251 | Everyone interacting in the BatchLoaderActiveRecord project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/mathieul/batch-loader-active-record/blob/master/CODE_OF_CONDUCT.md). 252 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | 8 | if !ENV["APPRAISAL_INITIALIZED"] && !ENV["TRAVIS"] 9 | task :default => :appraisal 10 | end -------------------------------------------------------------------------------- /batch-loader-active-record.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "batch_loader_active_record/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "batch-loader-active-record" 8 | spec.version = BatchLoaderActiveRecord::VERSION 9 | spec.authors = ["mathieul"] 10 | spec.email = ["mathieu@gmail.com"] 11 | 12 | spec.summary = %q{Active record lazy association generator leveraging batch-loader to avoid N+1 DB queries.} 13 | spec.description = %q{Active record lazy association generator leveraging batch-loader to avoid N+1 DB queries.} 14 | spec.homepage = "https://github.com/mathieul/batch-loader-active-record" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 18 | f.match(%r{^(test|spec|features)/}) 19 | end 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_dependency "batch-loader", "~> 1.2.0" 23 | spec.add_dependency "activerecord", ">= 4.2.0", "< 5.3.0" 24 | 25 | spec.add_development_dependency "bundler", "~> 1.16" 26 | spec.add_development_dependency "rake", "~> 10.0" 27 | spec.add_development_dependency "rspec", "~> 3.0" 28 | spec.add_development_dependency "pry-byebug", "~> 3.5" 29 | spec.add_development_dependency "sqlite3", "~> 1.3.13" 30 | spec.add_development_dependency "activesupport", ">= 4.2.0", "< 5.2.0" 31 | spec.add_development_dependency "appraisal", "~> 2.2.0" 32 | end 33 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "batch_loader_active_record" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /gemfiles/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_RETRY: "1" 3 | -------------------------------------------------------------------------------- /gemfiles/activerecord_4_2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "4.2.10" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/activerecord_4_2.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | batch-loader-active-record (0.5.0) 5 | activerecord (>= 4.2.0, < 5.3.0) 6 | batch-loader (~> 1.2.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | activemodel (4.2.10) 12 | activesupport (= 4.2.10) 13 | builder (~> 3.1) 14 | activerecord (4.2.10) 15 | activemodel (= 4.2.10) 16 | activesupport (= 4.2.10) 17 | arel (~> 6.0) 18 | activesupport (4.2.10) 19 | i18n (~> 0.7) 20 | minitest (~> 5.1) 21 | thread_safe (~> 0.3, >= 0.3.4) 22 | tzinfo (~> 1.1) 23 | appraisal (2.2.0) 24 | bundler 25 | rake 26 | thor (>= 0.14.0) 27 | arel (6.0.4) 28 | batch-loader (1.2.0) 29 | builder (3.2.3) 30 | byebug (9.1.0) 31 | coderay (1.1.2) 32 | concurrent-ruby (1.0.5) 33 | diff-lcs (1.3) 34 | i18n (0.9.1) 35 | concurrent-ruby (~> 1.0) 36 | method_source (0.9.0) 37 | minitest (5.10.3) 38 | pry (0.11.3) 39 | coderay (~> 1.1.0) 40 | method_source (~> 0.9.0) 41 | pry-byebug (3.5.1) 42 | byebug (~> 9.1) 43 | pry (~> 0.10) 44 | rake (10.5.0) 45 | rspec (3.7.0) 46 | rspec-core (~> 3.7.0) 47 | rspec-expectations (~> 3.7.0) 48 | rspec-mocks (~> 3.7.0) 49 | rspec-core (3.7.0) 50 | rspec-support (~> 3.7.0) 51 | rspec-expectations (3.7.0) 52 | diff-lcs (>= 1.2.0, < 2.0) 53 | rspec-support (~> 3.7.0) 54 | rspec-mocks (3.7.0) 55 | diff-lcs (>= 1.2.0, < 2.0) 56 | rspec-support (~> 3.7.0) 57 | rspec-support (3.7.0) 58 | sqlite3 (1.3.13) 59 | thor (0.20.0) 60 | thread_safe (0.3.6) 61 | tzinfo (1.2.4) 62 | thread_safe (~> 0.1) 63 | 64 | PLATFORMS 65 | ruby 66 | 67 | DEPENDENCIES 68 | activerecord (= 4.2.10) 69 | activesupport (>= 4.2.0, < 5.2.0) 70 | appraisal (~> 2.2.0) 71 | batch-loader-active-record! 72 | bundler (~> 1.16) 73 | pry-byebug (~> 3.5) 74 | rake (~> 10.0) 75 | rspec (~> 3.0) 76 | sqlite3 (~> 1.3.13) 77 | 78 | BUNDLED WITH 79 | 1.16.0 80 | -------------------------------------------------------------------------------- /gemfiles/activerecord_5_0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "5.0.6" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/activerecord_5_0.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | batch-loader-active-record (0.5.0) 5 | activerecord (>= 4.2.0, < 5.3.0) 6 | batch-loader (~> 1.2.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | activemodel (5.0.6) 12 | activesupport (= 5.0.6) 13 | activerecord (5.0.6) 14 | activemodel (= 5.0.6) 15 | activesupport (= 5.0.6) 16 | arel (~> 7.0) 17 | activesupport (5.0.6) 18 | concurrent-ruby (~> 1.0, >= 1.0.2) 19 | i18n (~> 0.7) 20 | minitest (~> 5.1) 21 | tzinfo (~> 1.1) 22 | appraisal (2.2.0) 23 | bundler 24 | rake 25 | thor (>= 0.14.0) 26 | arel (7.1.4) 27 | batch-loader (1.2.0) 28 | byebug (9.1.0) 29 | coderay (1.1.2) 30 | concurrent-ruby (1.0.5) 31 | diff-lcs (1.3) 32 | i18n (0.9.1) 33 | concurrent-ruby (~> 1.0) 34 | method_source (0.9.0) 35 | minitest (5.10.3) 36 | pry (0.11.3) 37 | coderay (~> 1.1.0) 38 | method_source (~> 0.9.0) 39 | pry-byebug (3.5.1) 40 | byebug (~> 9.1) 41 | pry (~> 0.10) 42 | rake (10.5.0) 43 | rspec (3.7.0) 44 | rspec-core (~> 3.7.0) 45 | rspec-expectations (~> 3.7.0) 46 | rspec-mocks (~> 3.7.0) 47 | rspec-core (3.7.0) 48 | rspec-support (~> 3.7.0) 49 | rspec-expectations (3.7.0) 50 | diff-lcs (>= 1.2.0, < 2.0) 51 | rspec-support (~> 3.7.0) 52 | rspec-mocks (3.7.0) 53 | diff-lcs (>= 1.2.0, < 2.0) 54 | rspec-support (~> 3.7.0) 55 | rspec-support (3.7.0) 56 | sqlite3 (1.3.13) 57 | thor (0.20.0) 58 | thread_safe (0.3.6) 59 | tzinfo (1.2.4) 60 | thread_safe (~> 0.1) 61 | 62 | PLATFORMS 63 | ruby 64 | 65 | DEPENDENCIES 66 | activerecord (= 5.0.6) 67 | activesupport (>= 4.2.0, < 5.2.0) 68 | appraisal (~> 2.2.0) 69 | batch-loader-active-record! 70 | bundler (~> 1.16) 71 | pry-byebug (~> 3.5) 72 | rake (~> 10.0) 73 | rspec (~> 3.0) 74 | sqlite3 (~> 1.3.13) 75 | 76 | BUNDLED WITH 77 | 1.16.0 78 | -------------------------------------------------------------------------------- /gemfiles/activerecord_5_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "5.1.4" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/activerecord_5_1.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | batch-loader-active-record (0.5.0) 5 | activerecord (>= 4.2.0, < 5.3.0) 6 | batch-loader (~> 1.2.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | activemodel (5.1.4) 12 | activesupport (= 5.1.4) 13 | activerecord (5.1.4) 14 | activemodel (= 5.1.4) 15 | activesupport (= 5.1.4) 16 | arel (~> 8.0) 17 | activesupport (5.1.4) 18 | concurrent-ruby (~> 1.0, >= 1.0.2) 19 | i18n (~> 0.7) 20 | minitest (~> 5.1) 21 | tzinfo (~> 1.1) 22 | appraisal (2.2.0) 23 | bundler 24 | rake 25 | thor (>= 0.14.0) 26 | arel (8.0.0) 27 | batch-loader (1.2.0) 28 | byebug (9.1.0) 29 | coderay (1.1.2) 30 | concurrent-ruby (1.0.5) 31 | diff-lcs (1.3) 32 | i18n (0.9.1) 33 | concurrent-ruby (~> 1.0) 34 | method_source (0.9.0) 35 | minitest (5.10.3) 36 | pry (0.11.3) 37 | coderay (~> 1.1.0) 38 | method_source (~> 0.9.0) 39 | pry-byebug (3.5.1) 40 | byebug (~> 9.1) 41 | pry (~> 0.10) 42 | rake (10.5.0) 43 | rspec (3.7.0) 44 | rspec-core (~> 3.7.0) 45 | rspec-expectations (~> 3.7.0) 46 | rspec-mocks (~> 3.7.0) 47 | rspec-core (3.7.0) 48 | rspec-support (~> 3.7.0) 49 | rspec-expectations (3.7.0) 50 | diff-lcs (>= 1.2.0, < 2.0) 51 | rspec-support (~> 3.7.0) 52 | rspec-mocks (3.7.0) 53 | diff-lcs (>= 1.2.0, < 2.0) 54 | rspec-support (~> 3.7.0) 55 | rspec-support (3.7.0) 56 | sqlite3 (1.3.13) 57 | thor (0.20.0) 58 | thread_safe (0.3.6) 59 | tzinfo (1.2.4) 60 | thread_safe (~> 0.1) 61 | 62 | PLATFORMS 63 | ruby 64 | 65 | DEPENDENCIES 66 | activerecord (= 5.1.4) 67 | activesupport (>= 4.2.0, < 5.2.0) 68 | appraisal (~> 2.2.0) 69 | batch-loader-active-record! 70 | bundler (~> 1.16) 71 | pry-byebug (~> 3.5) 72 | rake (~> 10.0) 73 | rspec (~> 3.0) 74 | sqlite3 (~> 1.3.13) 75 | 76 | BUNDLED WITH 77 | 1.16.0 78 | -------------------------------------------------------------------------------- /gemfiles/activerecord_5_2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "5.2.0.beta1" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/activerecord_5_2.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | batch-loader-active-record (0.5.0) 5 | activerecord (>= 4.2.0, < 5.3.0) 6 | batch-loader (~> 1.2.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | activemodel (5.2.0.beta1) 12 | activesupport (= 5.2.0.beta1) 13 | activerecord (5.2.0.beta1) 14 | activemodel (= 5.2.0.beta1) 15 | activesupport (= 5.2.0.beta1) 16 | arel (>= 9.0) 17 | activesupport (5.2.0.beta1) 18 | concurrent-ruby (~> 1.0, >= 1.0.2) 19 | i18n (~> 0.7) 20 | minitest (~> 5.1) 21 | tzinfo (~> 1.1) 22 | appraisal (2.2.0) 23 | bundler 24 | rake 25 | thor (>= 0.14.0) 26 | arel (9.0.0) 27 | batch-loader (1.2.0) 28 | byebug (9.1.0) 29 | coderay (1.1.2) 30 | concurrent-ruby (1.0.5) 31 | diff-lcs (1.3) 32 | i18n (0.9.1) 33 | concurrent-ruby (~> 1.0) 34 | method_source (0.9.0) 35 | minitest (5.10.3) 36 | pry (0.11.3) 37 | coderay (~> 1.1.0) 38 | method_source (~> 0.9.0) 39 | pry-byebug (3.5.1) 40 | byebug (~> 9.1) 41 | pry (~> 0.10) 42 | rake (10.5.0) 43 | rspec (3.7.0) 44 | rspec-core (~> 3.7.0) 45 | rspec-expectations (~> 3.7.0) 46 | rspec-mocks (~> 3.7.0) 47 | rspec-core (3.7.0) 48 | rspec-support (~> 3.7.0) 49 | rspec-expectations (3.7.0) 50 | diff-lcs (>= 1.2.0, < 2.0) 51 | rspec-support (~> 3.7.0) 52 | rspec-mocks (3.7.0) 53 | diff-lcs (>= 1.2.0, < 2.0) 54 | rspec-support (~> 3.7.0) 55 | rspec-support (3.7.0) 56 | sqlite3 (1.3.13) 57 | thor (0.20.0) 58 | thread_safe (0.3.6) 59 | tzinfo (1.2.4) 60 | thread_safe (~> 0.1) 61 | 62 | PLATFORMS 63 | ruby 64 | 65 | DEPENDENCIES 66 | activerecord (= 5.2.0.beta1) 67 | activesupport (>= 4.2.0, < 5.2.0) 68 | appraisal (~> 2.2.0) 69 | batch-loader-active-record! 70 | bundler (~> 1.16) 71 | pry-byebug (~> 3.5) 72 | rake (~> 10.0) 73 | rspec (~> 3.0) 74 | sqlite3 (~> 1.3.13) 75 | 76 | BUNDLED WITH 77 | 1.16.0 78 | -------------------------------------------------------------------------------- /lib/batch-loader-active-record.rb: -------------------------------------------------------------------------------- 1 | require_relative "./batch_loader_active_record" -------------------------------------------------------------------------------- /lib/batch_loader_active_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "batch-loader" 4 | require "batch_loader_active_record/version" 5 | require "batch_loader_active_record/association_manager" 6 | 7 | module BatchLoaderActiveRecord 8 | def self.included(base) 9 | base.extend(ClassMethods) 10 | end 11 | 12 | module ClassMethods 13 | def association_accessor(name) 14 | reflection = reflect_on_association(name) or raise "Can't find association #{name.inspect}" 15 | manager = AssociationManager.new(model: self, reflection: reflection) 16 | case reflection.macro 17 | when :belongs_to 18 | if reflection.polymorphic? 19 | define_method(manager.accessor_name) { manager.polymorphic_belongs_to_batch_loader(self) } 20 | else 21 | define_method(manager.accessor_name) { manager.belongs_to_batch_loader(self) } 22 | end 23 | when :has_one 24 | define_method(manager.accessor_name) { manager.has_one_to_batch_loader(self) } 25 | when :has_many 26 | define_method(manager.accessor_name) do |instance_scope = nil| 27 | manager.has_many_to_batch_loader(self, instance_scope) 28 | end 29 | when :has_and_belongs_to_many 30 | define_method(manager.accessor_name) do |instance_scope = nil| 31 | manager.has_and_belongs_to_many_to_batch_loader(self, instance_scope) 32 | end 33 | else 34 | raise NotImplementedError, "association kind #{reflection.macro.inspect} is not yet supported" 35 | end 36 | end 37 | 38 | def belongs_to_lazy(*args) 39 | belongs_to(*args).tap do 40 | reflection = reflect_on_all_associations.last 41 | manager = AssociationManager.new(model: self, reflection: reflection) 42 | if reflection.polymorphic? 43 | define_method(manager.accessor_name) { manager.polymorphic_belongs_to_batch_loader(self) } 44 | else 45 | define_method(manager.accessor_name) { manager.belongs_to_batch_loader(self) } 46 | end 47 | end 48 | end 49 | 50 | def has_one_lazy(*args) 51 | has_one(*args).tap do 52 | manager = AssociationManager.new(model: self, reflection: reflect_on_all_associations.last) 53 | define_method(manager.accessor_name) { manager.has_one_to_batch_loader(self) } 54 | end 55 | end 56 | 57 | def has_many_lazy(*args) 58 | has_many(*args).tap do 59 | manager = AssociationManager.new(model: self, reflection: reflect_on_all_associations.last) 60 | define_method(manager.accessor_name) do |instance_scope = nil| 61 | manager.has_many_to_batch_loader(self, instance_scope) 62 | end 63 | end 64 | end 65 | 66 | def has_and_belongs_to_many_lazy(*args) 67 | has_and_belongs_to_many(*args).tap do 68 | manager = AssociationManager.new(model: self, reflection: reflect_on_all_associations.last) 69 | define_method(manager.accessor_name) do |instance_scope = nil| 70 | manager.has_and_belongs_to_many_to_batch_loader(self, instance_scope) 71 | end 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/batch_loader_active_record/association_manager.rb: -------------------------------------------------------------------------------- 1 | module BatchLoaderActiveRecord 2 | class AssociationManager 3 | attr_reader :model, :reflection 4 | 5 | def initialize(model:, reflection:) 6 | @model = model 7 | @reflection = reflection 8 | end 9 | 10 | def accessor_name 11 | :"#{reflection.name}_lazy" 12 | end 13 | 14 | def belongs_to_batch_loader(instance) 15 | foreign_key_value = instance.send(reflection.foreign_key) or return nil 16 | BatchLoader.for(foreign_key_value).batch(key: batch_key) do |foreign_key_values, loader| 17 | target_scope.where(id: foreign_key_values).each { |instance| loader.call(instance.id, instance) } 18 | end 19 | end 20 | 21 | def polymorphic_belongs_to_batch_loader(instance) 22 | foreign_id = instance.send(reflection.foreign_key) or return nil 23 | foreign_type = instance.send(reflection.foreign_type)&.constantize or return nil 24 | BatchLoader.for([foreign_type, foreign_id]).batch(key: batch_key) do |foreign_ids_types, loader| 25 | foreign_ids_types 26 | .group_by(&:first) 27 | .each do |type, type_ids| 28 | ids = type_ids.map(&:second) 29 | klass_scope(type).where(id: ids).each do |instance| 30 | loader.call([type, instance.id], instance) 31 | end 32 | end 33 | end 34 | end 35 | 36 | def has_one_to_batch_loader(instance) 37 | BatchLoader.for(instance.id).batch(key: batch_key) do |model_ids, loader| 38 | relation = target_scope.where(reflection.foreign_key => model_ids) 39 | relation = relation.where(reflection.type => model.to_s) if reflection.type 40 | relation.each do |instance| 41 | loader.call(instance.public_send(reflection.foreign_key), instance) 42 | end 43 | end 44 | end 45 | 46 | def has_many_to_batch_loader(instance, instance_scope) 47 | custom_key = batch_key 48 | custom_key += [instance_scope.to_sql.hash] unless instance_scope.nil? 49 | BatchLoader.for(instance.id).batch(default_value: [], key: custom_key) do |model_ids, loader| 50 | relation = relation_with_scope(instance_scope) 51 | relation = relation.where(reflection.type => model.to_s) if reflection.type 52 | if reflection.through_reflection 53 | instances = fetch_for_model_ids(model_ids, relation: relation) 54 | instances.each do |instance| 55 | loader.call(instance.public_send(:_instance_id)) { |value| value << instance } 56 | end 57 | else 58 | relation.where(reflection.foreign_key => model_ids).each do |instance| 59 | loader.call(instance.public_send(reflection.foreign_key)) { |value| value << instance } 60 | end 61 | end 62 | end 63 | end 64 | 65 | def has_and_belongs_to_many_to_batch_loader(instance, instance_scope) 66 | custom_key = batch_key 67 | custom_key += [instance_scope.to_sql.hash] unless instance_scope.nil? 68 | BatchLoader.for(instance.id).batch(default_value: [], key: custom_key) do |model_ids, loader| 69 | instance_id_path = "#{reflection.join_table}.#{reflection.foreign_key}" 70 | relation_with_scope(instance_scope) 71 | .joins(habtm_join(reflection)) 72 | .where("#{reflection.join_table}.#{reflection.foreign_key} IN (?)", model_ids) 73 | .select("#{target_scope.table_name}.*, #{instance_id_path} AS _instance_id") 74 | .each do |instance| 75 | loader.call(instance.public_send(:_instance_id)) { |value| value << instance } 76 | end 77 | end 78 | end 79 | 80 | private 81 | 82 | def relation_with_scope(instance_scope) 83 | if instance_scope.nil? 84 | target_scope 85 | else 86 | target_scope.instance_eval { instance_scope } 87 | end 88 | end 89 | 90 | def target_scope 91 | @target_scope ||= if reflection.scope.nil? 92 | reflection.klass 93 | else 94 | reflection.klass.instance_eval(&reflection.scope) 95 | end 96 | end 97 | 98 | def klass_scope(klass) 99 | if reflection.scope.nil? 100 | klass 101 | else 102 | klass.instance_eval(&reflection.scope) 103 | end 104 | end 105 | 106 | def batch_key 107 | @batch_key ||= [model.table_name, reflection.name].freeze 108 | end 109 | 110 | def fetch_for_model_ids(ids, relation:) 111 | instance_id_path = "#{reflection.active_record.table_name}.#{reflection.active_record.primary_key}" 112 | model_class = reflection.active_record 113 | reflections = reflection_chain(reflection) 114 | join_strings = [reflection_join(reflections.first, relation)] 115 | reflections.each_cons(2) do |previous, current| 116 | join_strings << reflection_join(current, previous.active_record) 117 | end 118 | select_relation = join_strings.reduce(relation) do |select_relation, join_string| 119 | select_relation.joins(join_string) 120 | end 121 | select_relation 122 | .where("#{model_class.table_name}.#{model_class.primary_key} IN (?)", ids) 123 | .select("#{relation.table_name}.*, #{instance_id_path} AS _instance_id") 124 | end 125 | 126 | def reflection_chain(reflection) 127 | reflections = [reflection] 128 | begin 129 | previous = reflection 130 | reflection = previous.source_reflection 131 | if reflection && reflection != previous 132 | reflections << reflection 133 | else 134 | reflection = nil 135 | end 136 | end while reflection 137 | reflections.reverse 138 | end 139 | 140 | def reflection_join(orig_reflection, model_class) 141 | reflection = orig_reflection.through_reflection || orig_reflection 142 | id_path = id_path_for(reflection, model_class) 143 | table_name = reflection.active_record.table_name 144 | id_column = reflection.belongs_to? ? reflection.foreign_key : reflection.active_record.primary_key 145 | "INNER JOIN #{table_name} ON #{table_name}.#{id_column} = #{id_path}" 146 | end 147 | 148 | def id_path_for(reflection, model_class) 149 | id_column = if reflection.belongs_to? 150 | model_class.primary_key 151 | else 152 | reflection.foreign_key 153 | end 154 | "#{model_class.table_name}.#{id_column}" 155 | end 156 | 157 | def habtm_join(reflection) 158 | <<~SQL 159 | INNER JOIN #{reflection.join_table} 160 | ON #{reflection.join_table}.#{reflection.association_foreign_key} = 161 | #{reflection.klass.table_name}.#{reflection.active_record.primary_key} 162 | SQL 163 | end 164 | end 165 | end -------------------------------------------------------------------------------- /lib/batch_loader_active_record/version.rb: -------------------------------------------------------------------------------- 1 | module BatchLoaderActiveRecord 2 | VERSION = "0.5.0" 3 | end 4 | -------------------------------------------------------------------------------- /spec/belongs_to_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "lazy belongs_to associations" do 2 | before(:all) do 3 | Post = new_model(:post, published: :boolean) do 4 | scope :published, -> { where(published: true) } 5 | end 6 | Comment = new_model(:comment, post_id: :integer) do 7 | include BatchLoaderActiveRecord 8 | belongs_to_lazy :post 9 | belongs_to_lazy :published_post, -> { published }, class_name: 'Post', foreign_key: 'post_id' 10 | end 11 | end 12 | 13 | let(:created_posts) { [] } 14 | let(:comments) { 15 | 3.times.map do 16 | created_posts << (post = Post.create(published: false)) 17 | Comment.create!(post: post) 18 | end 19 | } 20 | 21 | before(:each) do 22 | Post.delete_all 23 | Comment.delete_all 24 | comments 25 | end 26 | 27 | context "compare regular and lazy solutions" do 28 | after(:each) { stop_query_monitor } 29 | 30 | it "runs 1 query per owner to fetch with regular relationship" do 31 | start_query_monitor 32 | posts = Comment.find(*comments.map(&:id)).map(&:post) 33 | expect(posts).to eq created_posts 34 | expect(monitored_queries.length).to eq(1 + 3) 35 | end 36 | 37 | it "runs 1 query for all the owners to fetch with lazy relationship" do 38 | start_query_monitor 39 | posts = Comment.find(*comments.map(&:id)).map(&:post_lazy) 40 | expect(posts).to eq created_posts 41 | expect(monitored_queries.length).to eq(1 + 1) 42 | end 43 | end 44 | 45 | it "can have a scope" do 46 | posts = created_posts.values_at(0, 2) 47 | posts.first.update!(published: true) 48 | comments = Comment.where(post_id: posts.map(&:id)) 49 | published_posts = comments.map(&:published_post_lazy) 50 | expect(published_posts).to eq [posts.first, nil] 51 | end 52 | 53 | it "can decouple describing the relationship and making it lazy" do 54 | CommentAuthor = new_model(:comment_author, comment_id: :integer) do 55 | include BatchLoaderActiveRecord 56 | belongs_to :comment 57 | association_accessor :comment 58 | end 59 | comments = [] 60 | authors = 2.times.map do 61 | comments << (comment = Comment.create) 62 | CommentAuthor.create(comment: comment) 63 | end 64 | expect(CommentAuthor.find(*authors.map(&:id)).map(&:comment_lazy)).to eq comments 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/has_and_belongs_to_many_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "lazy has_and_belongs_to_many associations" do 2 | before(:all) do 3 | create_join_table :role, :user 4 | User = new_model(:user) do 5 | include BatchLoaderActiveRecord 6 | has_and_belongs_to_many :roles 7 | association_accessor :roles 8 | end 9 | Role = new_model(:role, enabled: :boolean) do 10 | scope :enabled, -> { where(enabled: true) } 11 | end 12 | end 13 | 14 | let(:admin) { Role.create(enabled: true) } 15 | let(:agent) { Role.create(enabled: true) } 16 | let(:reporter) { Role.create(enabled: false) } 17 | let(:jane) { User.create } 18 | let(:joe) { User.create } 19 | 20 | before(:each) do 21 | User.delete_all 22 | Role.delete_all 23 | [admin, reporter].each { |role| jane.roles << role } 24 | joe.roles << agent 25 | end 26 | 27 | after(:each) { stop_query_monitor } 28 | 29 | it "runs 1 query per object to query regular relationship" do 30 | start_query_monitor 31 | User.find(jane.id, joe.id).each(&:roles) 32 | expect(jane.roles).to eq [admin, reporter] 33 | expect(joe.roles).to eq [agent] 34 | expect(monitored_queries.length).to eq (1 + 2) 35 | end 36 | 37 | it "runs 1 query for all objects to query lazy relationship" do 38 | start_query_monitor 39 | [jane, joe].each(&:roles_lazy) 40 | expect(jane.roles_lazy).to eq [admin, reporter] 41 | expect(joe.roles_lazy).to eq [agent] 42 | expect(monitored_queries.length).to eq 1 43 | end 44 | 45 | it "can pass a scope to specify dynamic association conditions" do 46 | expect(jane.roles_lazy(Role.enabled)).to eq [admin] 47 | expect(joe.roles_lazy(Role.enabled)).to eq [agent] 48 | end 49 | 50 | it "doesn't aggregate lazy associations with different scopes" do |variable| 51 | expect(jane.roles_lazy).to eq [admin, reporter] 52 | expect(jane.roles_lazy(Role.enabled)).to eq [admin] 53 | end 54 | 55 | it "can use a 1-liner to declare an association and generate a lazy accessor" do 56 | create_join_table :person, :quality 57 | Person = new_model(:person) do 58 | include BatchLoaderActiveRecord 59 | has_and_belongs_to_many_lazy :qualities 60 | end 61 | Quality = new_model(:quality) 62 | 63 | strong, smart = 2.times.map { Quality.create } 64 | lizzy = Person.create 65 | lizzy.qualities << smart 66 | expect(lizzy.qualities_lazy).to eq [smart] 67 | end 68 | end -------------------------------------------------------------------------------- /spec/has_many_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "lazy has_many associations" do 2 | after(:each) { stop_query_monitor } 3 | 4 | describe "has_many_lazy" do 5 | before(:all) do 6 | Contact = new_model(:contact) do 7 | include BatchLoaderActiveRecord 8 | has_many_lazy :phone_numbers 9 | has_many_lazy :us_phone_numbers, -> { usa }, class_name: 'PhoneNumber', foreign_key: 'contact_id' 10 | end 11 | PhoneNumber = new_model(:phone_number, contact_id: :integer, enabled: :boolean, country_code: :integer) do 12 | scope :enabled, -> { where(enabled: true) } 13 | scope :usa, -> { where(country_code: 1) } 14 | end 15 | end 16 | 17 | let(:created_phone_numbers) { [] } 18 | let(:contacts) { 19 | 3.times.map do 20 | Contact.create.tap do |contact| 21 | created_phone_numbers << PhoneNumber.create(contact_id: contact.id, enabled: true, country_code: 1) 22 | created_phone_numbers << PhoneNumber.create(contact_id: contact.id, enabled: false, country_code: 1) 23 | end 24 | end 25 | } 26 | 27 | before(:each) do 28 | Contact.delete_all 29 | PhoneNumber.delete_all 30 | contacts 31 | end 32 | 33 | it "runs 1 query per object to fetch children with regular relationship" do 34 | start_query_monitor 35 | phone_numbers = Contact.find(*contacts.map(&:id)).map(&:phone_numbers).flatten 36 | expect(phone_numbers).to eq created_phone_numbers 37 | expect(monitored_queries.length).to eq (1 + 3) 38 | end 39 | 40 | it "runs 1 query to fetch all children with lazy relationship" do 41 | start_query_monitor 42 | phone_numbers = Contact.find(*contacts.map(&:id)).map(&:phone_numbers_lazy).flatten 43 | expect(phone_numbers).to eq created_phone_numbers 44 | expect(monitored_queries.length).to eq (1 + 1) 45 | end 46 | 47 | it "can have a scope" do 48 | phone_numbers = PhoneNumber.first(4) 49 | phone_numbers.first.update!(country_code: 33) 50 | phone_numbers.fourth.update!(country_code: 44) 51 | contacts = Contact.find(*phone_numbers.map(&:contact_id).uniq) 52 | us_numbers = contacts.map(&:us_phone_numbers_lazy).flatten 53 | expect(us_numbers).to eq [phone_numbers.second, phone_numbers.third] 54 | end 55 | 56 | it "can pass a scope to specify children conditions" do 57 | enabled_phone_numbers = created_phone_numbers.select(&:enabled?) 58 | start_query_monitor 59 | phone_numbers = Contact 60 | .find(*contacts.map(&:id)) 61 | .map { |contact| contact.phone_numbers_lazy(PhoneNumber.enabled) } 62 | .flatten 63 | 64 | expect(phone_numbers).to eq enabled_phone_numbers 65 | expect(monitored_queries.length).to eq (1 + 1) 66 | end 67 | 68 | it "can decouple describing the relationship and making it lazy" do 69 | EmailAddress = new_model(:EmailAddress, contact_id: :integer) 70 | Contact.instance_eval do 71 | has_many :email_addresses 72 | association_accessor :email_addresses 73 | end 74 | email_addresses = [] 75 | contacts = 2.times.map do 76 | Contact.create.tap do |contact| 77 | email_addresses << EmailAddress.create(contact_id: contact.id) 78 | email_addresses << EmailAddress.create(contact_id: contact.id) 79 | end 80 | end 81 | expect(Contact.find(*contacts.map(&:id)).map(&:email_addresses_lazy).flatten).to eq email_addresses 82 | end 83 | end 84 | 85 | describe "has_many_lazy through: ..." do 86 | before(:all) do 87 | Agent = new_model(:agent) do 88 | include BatchLoaderActiveRecord 89 | has_many :phones 90 | has_many_lazy :providers, through: :phones 91 | end 92 | Phone = new_model(:phone, agent_id: :integer) do 93 | has_many :calls 94 | has_many :providers, through: :calls 95 | end 96 | Call = new_model(:call, phone_id: :integer, provider_id: :integer) do 97 | belongs_to :provider 98 | end 99 | Provider = new_model(:provider, enabled: :boolean, status: :string) do 100 | scope :enabled, -> { where(enabled: true) } 101 | end 102 | end 103 | 104 | let(:all_agents) { 105 | 3.times.map do 106 | Agent.create.tap do |agent| 107 | [true, false].each do |enabled| 108 | phone_number = Phone.create(agent_id: agent.id) 109 | provider = Provider.create(enabled: enabled) 110 | Call.create(phone_id: phone_number.id, provider_id: provider.id) 111 | end 112 | end 113 | end 114 | } 115 | let(:agents) { [all_agents.first, all_agents.last] } 116 | let(:providers) { agents.flat_map(&:phones).flat_map(&:calls).flat_map(&:provider) } 117 | 118 | before(:each) do 119 | Call.delete_all 120 | Provider.delete_all 121 | Phone.delete_all 122 | Agent.delete_all 123 | providers 124 | end 125 | 126 | it "runs 1 query per object to fetch children with regular relationship" do 127 | start_query_monitor 128 | providers_fetched = Agent.find(*agents.map(&:id)).map(&:providers).flatten 129 | expect(providers_fetched).to eq providers 130 | expect(monitored_queries.length).to eq (1 + 2) 131 | end 132 | 133 | it "runs 1 query for all the owners to fetch with lazy relationship" do 134 | start_query_monitor 135 | providers_fetched = Agent.find(*agents.map(&:id)).map(&:providers_lazy).flatten 136 | expect(providers_fetched).to eq providers 137 | expect(monitored_queries.length).to eq (1 + 1) 138 | end 139 | 140 | it "can pass a scope to specify children conditions" do 141 | enabled_providers = providers.select(&:enabled?) 142 | start_query_monitor 143 | providers_fetched = Agent 144 | .find(*agents.map(&:id)) 145 | .map { |agent| agent.providers_lazy(Provider.enabled) } 146 | .flatten 147 | 148 | expect(providers_fetched).to eq enabled_providers 149 | expect(monitored_queries.length).to eq (1 + 1) 150 | end 151 | end 152 | end -------------------------------------------------------------------------------- /spec/has_one_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "lazy has_one associations" do 2 | before(:all) do 3 | Account = new_model(:account) do 4 | include BatchLoaderActiveRecord 5 | has_one_lazy :affiliate 6 | has_one_lazy :enabled_affiliate, -> { enabled }, class_name: 'Affiliate', foreign_key: 'account_id' 7 | end 8 | Affiliate = new_model(:affiliate, account_id: :integer, enabled: :boolean) do 9 | scope :enabled, -> { where(enabled: true) } 10 | end 11 | end 12 | 13 | let(:created_accounts) { [] } 14 | let(:created_affiliates) { 15 | 3.times.map do 16 | created_accounts << (account = Account.create) 17 | Affiliate.create(account_id: account.id, enabled: true) 18 | end 19 | } 20 | 21 | before(:each) do 22 | Account.delete_all 23 | Affiliate.delete_all 24 | created_affiliates 25 | end 26 | 27 | after(:each) { stop_query_monitor } 28 | 29 | it "runs 1 query per owner to fetch with regular relationship" do 30 | start_query_monitor 31 | affiliates = Account.find(created_accounts.map(&:id)).map(&:affiliate) 32 | expect(affiliates).to eq created_affiliates 33 | expect(monitored_queries.length).to eq(1 + 3) 34 | end 35 | 36 | it "runs 1 query for all the owners to fetch with lazy relationship" do 37 | start_query_monitor 38 | affiliates = Account.find(created_accounts.map(&:id)).map(&:affiliate_lazy) 39 | expect(affiliates).to eq created_affiliates 40 | expect(monitored_queries.length).to eq(1 + 1) 41 | end 42 | 43 | it "can have a scope" do 44 | affiliates = created_affiliates.values_at(0, 2) 45 | affiliates.first.update!(enabled: false) 46 | accounts = Account.find(*affiliates.map(&:account_id)) 47 | enabled_affiliates = accounts.map(&:enabled_affiliate_lazy) 48 | expect(enabled_affiliates).to eq [nil, affiliates.second] 49 | end 50 | 51 | it "can decouple describing the relationship and making it lazy" do 52 | AccountProfile = new_model(:account_profile, account_id: :integer) 53 | Account.instance_eval do 54 | include BatchLoaderActiveRecord 55 | has_one :account_profile 56 | association_accessor :account_profile 57 | end 58 | accounts = [] 59 | profiles = 2.times.map do 60 | accounts << (account = Account.create) 61 | AccountProfile.create(account_id: account.id) 62 | end 63 | expect(Account.find(*accounts.map(&:id)).map(&:account_profile_lazy)).to eq profiles 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/polymorphic_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe "polymorphic associations" do 2 | before(:all) do 3 | Tag = new_model(:tag, taggable_id: :integer, taggable_type: :string) do 4 | include BatchLoaderActiveRecord 5 | belongs_to :taggable, polymorphic: true 6 | association_accessor :taggable 7 | end 8 | Address = new_model(:address) do 9 | include BatchLoaderActiveRecord 10 | has_one :tag, as: :taggable 11 | association_accessor :tag 12 | end 13 | Ticket = new_model(:ticket) do 14 | include BatchLoaderActiveRecord 15 | has_many :tags, as: :taggable 16 | association_accessor :tags 17 | end 18 | end 19 | 20 | let(:addresses) { 3.times.map { Address.create } } 21 | let(:tickets) { 3.times.map { Ticket.create } } 22 | 23 | before(:each) do 24 | Tag.delete_all 25 | Address.delete_all 26 | Ticket.delete_all 27 | addresses.each { |address| Tag.create(taggable: address) } 28 | tickets.each { |ticket| 2.times { Tag.create(taggable: ticket) } } 29 | end 30 | 31 | describe "fetch a polymorphic association" do 32 | it "can fetch a polymorphic association from a has_one association" do 33 | selected = Address.find(addresses.first.id, addresses.third.id) 34 | expected_tags = Tag.where(taggable_id: selected.map(&:id), taggable_type: 'Address') 35 | expect(selected.map(&:tag_lazy)).to match_array expected_tags 36 | end 37 | 38 | it "can fetch a polymorphic association from a has_many association" do 39 | selected = Ticket.find(tickets.first.id, tickets.third.id) 40 | expected_tags = Tag.where(taggable_id: selected.map(&:id), taggable_type: 'Ticket') 41 | expect(selected.map(&:tags_lazy).flatten).to eq expected_tags 42 | end 43 | end 44 | 45 | describe "fetching the polymorphs" do 46 | before(:each) do 47 | addresses.each { |address| Tag.create(taggable: address) } 48 | tickets.each { |ticket| 2.times { Tag.create(taggable: ticket) } } 49 | end 50 | 51 | let(:address_tags) { Tag.where(taggable_id: addresses.map(&:id), taggable_type: 'Address') } 52 | let(:ticket_tags) { Tag.where(taggable_id: tickets.map(&:id), taggable_type: 'Ticket') } 53 | 54 | it "can fetch several types from the polymorphic association" do 55 | tags = address_tags + ticket_tags 56 | start_query_monitor 57 | expect(tags.map(&:taggable_lazy).uniq).to match_array(addresses + tickets) 58 | expect(monitored_queries.length).to eq 2 59 | stop_query_monitor 60 | end 61 | end 62 | end -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "batch_loader_active_record" 3 | require "pry" 4 | 5 | Dir[File.join(__dir__, "support/**/*.rb")].each(&method(:require)) 6 | 7 | RSpec.configure do |config| 8 | # Enable flags like --only-failures and --next-failure 9 | config.example_status_persistence_file_path = ".rspec_status" 10 | 11 | # Disable RSpec exposing methods globally on `Module` and `main` 12 | config.disable_monkey_patching! 13 | 14 | config.expect_with :rspec do |c| 15 | c.syntax = :expect 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/support/active_record_helpers.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'active_support/notifications' 3 | require 'securerandom' 4 | 5 | # Establish database connection 6 | ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') 7 | 8 | module ActiveRecordHelpers 9 | def new_model(create_table, fields = {}, &block) 10 | table_name = single_table_name(create_table) 11 | model = Class.new(ActiveRecord::Base) do 12 | self.table_name = table_name 13 | connection.create_table(table_name, :force => true) do |table| 14 | fields.each { |name, type| table.public_send(type, name) } 15 | end 16 | 17 | singleton_class.class_eval do 18 | define_method(:name) { "#{create_table.to_s.capitalize}" } 19 | end 20 | end 21 | model.class_eval(&block) if block_given? 22 | model.reset_column_information 23 | model 24 | end 25 | 26 | def create_join_table(*names) 27 | table_name = join_table_name(names) 28 | ActiveRecord::Base.connection.create_table(table_name, id: false) do |t| 29 | names.each { |name| t.column :"#{name}_id", :integer } 30 | end 31 | end 32 | 33 | def join_table_name(names) 34 | names.map(&method(:single_table_name)).sort.join('_') 35 | end 36 | 37 | def single_table_name(name) 38 | name.to_s.pluralize 39 | end 40 | 41 | attr_reader :monitored_queries 42 | 43 | def start_query_monitor 44 | @monitored_queries = [] 45 | @subscriber = ActiveSupport::Notifications.subscribe('sql.active_record') do |*, payload| 46 | @monitored_queries << payload[:sql] 47 | end 48 | end 49 | 50 | def stop_query_monitor 51 | return unless @subscriber 52 | ActiveSupport::Notifications.unsubscribe(@subscriber) 53 | @subscriber = nil 54 | end 55 | end 56 | 57 | RSpec.configure do |config| 58 | config.include ActiveRecordHelpers 59 | end 60 | --------------------------------------------------------------------------------