Repository: djezzzl/n1_loader Branch: master Commit: f54f236b22e8 Files: 79 Total size: 126.5 KB Directory structure: gitextract_b233zkyp/ ├── .github/ │ ├── FUNDING.yml │ ├── copilot-instructions.md │ └── workflows/ │ ├── copilot-setup-steps.yml │ ├── release.yml │ ├── rubocop.yml │ └── tests.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── activerecord-gemfiles/ │ ├── ar_5_latest.gemfile │ ├── ar_6_latest.gemfile │ ├── ar_7_latest.gemfile │ └── ar_8_latest.gemfile ├── ar_lazy_preload-gemfiles/ │ ├── ar_lazy_preload_0.6.1.gemfile │ └── ar_lazy_preload_master.gemfile ├── bin/ │ ├── console │ └── setup ├── examples/ │ ├── active_record_integration.rb │ ├── ar_lazy_integration.rb │ ├── ar_lazy_integration_with_isolated_loader.rb │ ├── arguments_support.rb │ ├── context/ │ │ ├── service.rb │ │ ├── setup_ar_lazy.rb │ │ └── setup_database.rb │ ├── core.rb │ ├── goldiloader_integration.rb │ ├── goldiloader_integration_with_isolated_loader.rb │ ├── graphql.rb │ ├── isolated_loader.rb │ ├── lazy_loading.rb │ ├── n1_bind_to.rb │ ├── reloading.rb │ ├── shared_loader.rb │ └── single_case.rb ├── goldiloader-gemfiles/ │ └── goldiloader.gemfile ├── guides/ │ └── enhanced-activerecord.md ├── lib/ │ ├── n1_loader/ │ │ ├── active_record/ │ │ │ ├── associations_preloader.rb │ │ │ ├── associations_preloader_v5.rb │ │ │ ├── associations_preloader_v6.rb │ │ │ ├── base.rb │ │ │ ├── loader.rb │ │ │ └── loader_collection.rb │ │ ├── active_record.rb │ │ ├── ar_lazy_preload/ │ │ │ ├── associated_context_builder.rb │ │ │ ├── context.rb │ │ │ ├── context_adapter.rb │ │ │ ├── loadable.rb │ │ │ ├── loader.rb │ │ │ ├── loader_collection_patch.rb │ │ │ ├── loader_patch.rb │ │ │ └── preloader_patch.rb │ │ ├── ar_lazy_preload.rb │ │ ├── core/ │ │ │ ├── loadable.rb │ │ │ ├── loader.rb │ │ │ ├── loader_builder.rb │ │ │ ├── loader_collection.rb │ │ │ └── preloader.rb │ │ ├── goldiloader/ │ │ │ ├── context.rb │ │ │ ├── context_adapter.rb │ │ │ ├── loadable.rb │ │ │ ├── loader.rb │ │ │ ├── loader_collection_patch.rb │ │ │ ├── loader_patch.rb │ │ │ └── preloader_patch.rb │ │ ├── goldiloader.rb │ │ └── version.rb │ └── n1_loader.rb ├── n1_loader.gemspec └── spec/ ├── activerecord_spec.rb ├── ar_lazy_preload_spec.rb ├── goldiloader_spec.rb ├── n1_loader_spec.rb └── spec_helper.rb ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # Supported platforms for funding open_collective: n1_loader github: djezzzl ================================================ FILE: .github/copilot-instructions.md ================================================ # Copilot Instructions Every PR must have no errors from: ``` bundle exec rubocop bundle exec rspec spec/n1_loader_spec.rb bundle exec rspec spec/n1_loader_spec.rb spec/activerecord_spec.rb bundle exec rspec spec/n1_loader_spec.rb spec/activerecord_spec.rb spec/ar_lazy_preload_spec.rb ``` ================================================ FILE: .github/workflows/copilot-setup-steps.yml ================================================ name: Copilot Setup Steps # Automatically run the setup steps when they are changed to allow for easy validation, and # allow manual testing through the repository's "Actions" tab on: workflow_dispatch: push: paths: - .github/workflows/copilot-setup-steps.yml pull_request: paths: - .github/workflows/copilot-setup-steps.yml jobs: copilot-setup-steps: runs-on: ubuntu-latest permissions: contents: read env: ACTIVERECORD_GEMFILE: 'ar_8_latest' AR_LAZY_PRELOAD_GEMFILE: 'ar_lazy_preload_master' steps: - uses: actions/checkout@v3 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: head - name: Install dependencies run: bundle install ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: workflow_dispatch: inputs: version: description: 'Version to release (e.g. 2.1.4)' required: true jobs: release: runs-on: ubuntu-latest if: github.actor == 'djezzzl' permissions: contents: write steps: - uses: actions/checkout@v3 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: '3.2' bundler-cache: true - name: Install dependencies run: bundle install - name: Validate version format run: | if ! echo "${{ github.event.inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then echo "Invalid version format: ${{ github.event.inputs.version }}. Expected semver (e.g. 1.2.3)" exit 1 fi - name: Bump version run: | sed -i 's/VERSION = ".*"/VERSION = "${{ github.event.inputs.version }}"/' lib/n1_loader/version.rb - name: Update changelog env: VERSION: ${{ github.event.inputs.version }} run: | DATE=$(date +%Y/%m/%d) sed -i.bak -e "s|^## \[Unreleased\]|## [$VERSION] - $DATE|" CHANGELOG.md rm -f CHANGELOG.md.bak - name: Git status run: git status - name: Commit version bump run: | git config user.name "Evgenii Demin" git config user.email "lawliet.djez@gmail.com" git add lib/n1_loader/version.rb CHANGELOG.md git commit -m "Release v${{ github.event.inputs.version }}" git push origin HEAD - name: Release gem env: GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} run: bundle exec rake release ================================================ FILE: .github/workflows/rubocop.yml ================================================ name: Rubocop on: pull_request: schedule: - cron: '0 0 * * 0' jobs: rubocop: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: 2.7 - name: Install dependencies run: bundle install - name: Run Rubocop run: bundle exec rubocop ================================================ FILE: .github/workflows/tests.yml ================================================ name: RSpec tests on: pull_request: schedule: - cron: '0 0 * * 0' jobs: tests: runs-on: ubuntu-latest continue-on-error: true strategy: matrix: include: - ruby-version: '2.7' activerecord-gemfile: 'ar_5_latest' ar_lazy_preload-gemfile: 'ar_lazy_preload_0.6.1' - ruby-version: '2.7' activerecord-gemfile: 'ar_6_latest' ar_lazy_preload-gemfile: 'ar_lazy_preload_0.6.1' - ruby-version: '3.0' activerecord-gemfile: 'ar_6_latest' ar_lazy_preload-gemfile: 'ar_lazy_preload_0.6.1' - ruby-version: '3.0' activerecord-gemfile: 'ar_7_latest' ar_lazy_preload-gemfile: 'ar_lazy_preload_master' - ruby-version: 'head' activerecord-gemfile: 'ar_7_latest' ar_lazy_preload-gemfile: 'ar_lazy_preload_master' - ruby-version: 'head' activerecord-gemfile: 'ar_8_latest' ar_lazy_preload-gemfile: 'ar_lazy_preload_master' env: ACTIVERECORD_GEMFILE: ${{ matrix.activerecord-gemfile }} AR_LAZY_PRELOAD_GEMFILE: ${{ matrix.ar_lazy_preload-gemfile }} steps: - uses: actions/checkout@v3 - name: Set up Ruby ${{ matrix.ruby-version }} uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} - name: Install dependencies run: bundle install - name: Run Core tests run: bundle exec rspec spec/n1_loader_spec.rb - name: Run ActiveRecord tests run: bundle exec rspec spec/n1_loader_spec.rb spec/activerecord_spec.rb - name: Run ArLazyPreload tests run: bundle exec rspec spec/n1_loader_spec.rb spec/activerecord_spec.rb spec/ar_lazy_preload_spec.rb - name: Run Goldiloader tests run: bundle exec rspec spec/n1_loader_spec.rb spec/activerecord_spec.rb spec/ar_lazy_preload_spec.rb ================================================ FILE: .gitignore ================================================ /.bundle/ /.yardoc /_yardoc/ /coverage/ /doc/ /pkg/ /spec/reports/ /tmp/ /.idea .rspec_status Gemfile.lock .ruby-version /vendor ================================================ FILE: .rspec ================================================ --format documentation --color --require spec_helper ================================================ FILE: .rubocop.yml ================================================ AllCops: TargetRubyVersion: 2.5 Exclude: - examples/**/* - vendor/bundle/**/* Style/StringLiterals: Enabled: true EnforcedStyle: double_quotes Style/StringLiteralsInInterpolation: Enabled: true EnforcedStyle: double_quotes Layout/LineLength: Max: 120 Metrics/BlockLength: Exclude: - spec/**/* ================================================ FILE: CHANGELOG.md ================================================ ## [3.0.0] - 2026/03/18 - Add support of [Goldiloader](https://github.com/KentaaNL/goldiloader) ## [2.2.1] - 2026/03/17 - Fix a rare thread-safety issue for setting context - Raise errors if `n1_bind_to` received unexpected arguments - Fix context propagation to loaded objects when using `n1_bind_to` ## [2.2.0] - 2026/03/16 - Support both identity and equality comparison in `Loader#for`. Identity lookup (via `object_id`) is tried first, with equality lookup as a fallback. Thanks [Alfonso Uceda](https://github.com/AlfonsoUceda) for reporting the issue! - Clear binding when `n1_clear_cache` is called, so objects load independently after a cache reset. ## [2.1.0] - 2026/03/07 - Add `n1_bind_to` to support context sharing for plain Ruby objects without ActiveRecord, enabling N+1-free batch loading. Nested loading through N1Loader automatically propagates the shared context. Thanks [Pawel Pacana](https://github.com/paneq) for the feature request! ## [2.0.1] - 2025/12/28 - Add support of Rails 8. ## [2.0.0] - 2025/12/28 - Make loader thread-safe. - Make loader idempotent. ## [1.7.4] - 2023/12/18 - Fix nested association does not preload properly. Thanks [dannyongtey](https://github.com/dannyongtey) for reporting and fixing the issue! ## [1.7.3] - 2023/08/04 - Decrease the package size by 60%. ## [1.7.2] - 2023/08/04 - Refactor core that ended up with speed boost. ## [1.7.1] - 2023/07/30 - Fix interface discrepancy for `N1LoaderReflection`. Thanks [Denis Talakevich](https://github.com/senid231) for suggesting it! ## [1.7.0] - 2023/07/30 Extend the flexibility of loading data comparison. Thanks [Nazar Matus](https://github.com/FunkyloverOne) for suggesting it! **BREAKING CHANGES:** Loose comparison of loaded data. Before loaded data was initialized with identity comparator in mind: ```ruby @loaded = {}.compare_by_identity ``` Now it will be: ```ruby @loaded = {} ``` This might bring unwanted results for cases when strict comparison was wanted. On the other hand, it gives more flexibility for many other cases, especially with isolated loader. For example, this will work now, when it wasn't working before. ```ruby # ActiveRecord object object = Entity.first # Initialize isolated loader instance = loader.new([object]) # This was working before because the loaded object is identical to passed object by `#object_id` instance.for(object) # This wasn't working before because the loaded object is not identical to passed one by `#object_id` # # But it will be working now, because object == Entity.find(object.id) instance.for(Entity.find(object.id)) ``` If you need strict comparison support, please feel free to open the issue or the PR. ## [1.6.6] - 2023/07/30 - Fix naive check of required arguments. Thanks [Nazar Matus](https://github.com/FunkyloverOne) for the issue! ## [1.6.5] - 2023/07/30 - Fix nested preloading for ActiveRecord 7. Thanks [Igor Gonchar](https://github.com/gigorok) for the issue! ## [1.6.4] - 2023/07/30 - Add support of `n1_optimized` ending with `?` (question mark). Thanks [Ilya Kamenko](https://github.com/Galathius) for the suggestion! ## [1.6.3] - 2022/12/30 - Performance optimization: avoid unnecessary calls. Thanks [Nazar Matus](https://github.com/FunkyloverOne) for the [contribution](https://github.com/djezzzl/n1_loader/pull/33). ## [1.6.2] - 2022/11/23 - Add fund metadata ## [1.6.1] - 2022/10/29 - Fix ArLazyPreload context setup when using isolated loaders for objects without the context. ## [1.6.0] - 2022/10/24 - Add support of ArLazyPreload context for isolated loaders. ## [1.5.1] - 2022/09/20 - Fix support of falsey value of arguments. Thanks [Aitor Lopez Beltran](https://github.com/aitorlb) for the [contribution](https://github.com/djezzzl/n1_loader/pull/23)! ## [1.5.0] - 2022/05/01 - Add support of Rails 7 ## [1.4.4] - 2022/04/29 - Inject `N1Loader::Loadable` to `ActiveRecord::Base` automatically - Make `reload` to call `n1_clear_cache` ## [1.4.3] - 2022-04-13 - Add `default` support to arguments ## [1.4.2] - 2022-03-01 - Add n1_clear_cache method which is useful for cases like reload in ActiveRecord ## [1.4.1] - 2022-02-24 - Fix preloading of invalid objects ## [1.4.0] - 2022-02-22 - add support of optional arguments BREAKING CHANGES: - rework arguments to use single definition through `argument ` only - use keyword arguments ## [1.3.0] - 2022-02-22 - add support of named arguments with `argument ` BREAKING CHANGES: - rename `n1_load` to `n1_optimized` - rework `def self.arguments_key` to `cache_key` ## [1.2.0] - 2022-01-14 - Introduce arguments support. ## [1.1.0] - 2021-12-27 - Introduce `fulfill` method to abstract the storage. ## [1.0.0] - 2021-12-26 - Various of great features. ## [0.1.0] - 2021-12-16 - Initial release. ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at TODO: Write your email address. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: Gemfile ================================================ # frozen_string_literal: true source "https://rubygems.org" # Specify your gem's dependencies in n1_loader.gemspec gemspec # Hack to make Github work with Circle CI job names with slashes gemfiles = [] gemfiles << "activerecord-gemfiles/#{ENV["ACTIVERECORD_GEMFILE"]}.gemfile" if ENV["ACTIVERECORD_GEMFILE"] gemfiles << "ar_lazy_preload-gemfiles/#{ENV["AR_LAZY_PRELOAD_GEMFILE"]}.gemfile" if ENV["AR_LAZY_PRELOAD_GEMFILE"] gemfiles << "goldiloader-gemfiles/#{ENV["GOLDILOADER_GEMFILE"]}.gemfile" if ENV["GOLDILOADER_GEMFILE"] gemfiles.each do |path| eval(File.read(path)) # rubocop:disable Security/Eval end ================================================ FILE: LICENSE.txt ================================================ The MIT License (MIT) Copyright (c) 2021 TODO: Write your name Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # N1Loader [![Gem Version][3]][4] [![][11]][12] [![][13]][14] [![][9]][10] N1Loader is designed to provide a simple way for avoiding [N+1 issues][7] of any kind. For example, it can help with resolving N+1 for: - database querying (most common case) - 3rd party service calls - complex calculations - and many more > If the project helps you or your organization, I would be very grateful if you [contribute][15] or [donate][10]. > Your support is an incredible motivation and the biggest reward for my hard work. ___Support:___ ActiveRecord 5, 6, 7, and 8. Follow me and stay tuned for the updates: - [LinkedIn](https://www.linkedin.com/in/evgeniydemin/) - [Medium](https://evgeniydemin.medium.com/) - [Twitter](https://twitter.com/EvgeniyDemin/) - [GitHub](https://github.com/djezzzl) ## Killer feature for GraphQL API N1Loader in combination with [ArLazyPreload][6] or [Goldiloader][16] is a killer feature for your GraphQL API. Give it a try now and see incredible results instantly! Check out the [example](examples/graphql.rb) and start benefiting from it in your projects! ```ruby gem 'n1_loader', require: 'n1_loader/ar_lazy_preload' # or gem 'n1_loader', require: 'n1_loader/goldiloader' ``` ## Enhance [ActiveRecord][5] Are you working with well-known Rails application? Try it out and see how well N1Loader fulfills missing gaps when you can't define ActiveRecord associations! Check out the detailed [guide](guides/enhanced-activerecord.md) with examples or its [short version](examples/active_record_integration.rb). ```ruby gem 'n1_loader', require: 'n1_loader/active_record' ``` Are you ready to forget about N+1 once and for all? Install [ArLazyPreload][6] or [Goldiloader][16] and see dreams come true! ```ruby gem 'n1_loader', require: 'n1_loader/ar_lazy_preload' # or gem 'n1_loader', require: 'n1_loader/goldiloader' ``` ## Standalone mode Are you not working with [ActiveRecord][5]? N1Loader is ready to be used as standalone solution! ([full snippet](examples/core.rb)) ```ruby gem 'n1_loader' ``` Want lazy, N+1-free loading without explicit preloading? Use `n1_bind_to` to share context across a collection of plain Ruby objects. ([full snippet](examples/n1_bind_to.rb)) ```ruby users = [User.new, User.new, User.new] # Bind users to the collection — lazy access is now automatically batched users.each { |user| user.n1_bind_to(users) } users.map(&:optimized_call) # loads all in a single batch, no N+1 ``` ## How to use it? N1Loader provides DSL that allows you to define N+1 ready loaders that can be injected into your objects in a way that you can avoid N+1 issues. > _Disclaimer_: examples below are working but designed to show N1Loader potentials only. In real live applications, N1Loader can be applied anywhere and in more [elegant way](examples/isolated_loader.rb). Let's look at simple example below ([full snippet](examples/active_record_integration.rb)): ```ruby class User < ActiveRecord::Base has_many :payments n1_optimized :payments_total do |users| total_per_user = Payment.group(:user_id) .where(user: users) .sum(:amount) .tap { |h| h.default = 0 } users.each do |user| total = total_per_user[user.id] fulfill(user, total) end end end class Payment < ActiveRecord::Base belongs_to :user validates :amount, presence: true end # A user has many payments. # Assuming, we want to know for group of users, what is a total of their payments, we can do the following: # Has N+1 issue p User.all.map { |user| user.payments.sum(&:amount) } # Has no N+1 but we load too many data that we don't actually need p User.all.includes(:payments).map { |user| user.payments.sum(&:amount) } # Has no N+1 and we load only what we need p User.all.includes(:payments_total).map { |user| user.payments_total } ``` Let's assume now, that we want to calculate the total of payments for the given period for a group of users. N1Loader can do that as well! ([full snippet](examples/arguments_support.rb)) ```ruby class User < ActiveRecord::Base has_many :payments n1_optimized :payments_total do argument :from argument :to def perform(users) total_per_user = Payment .group(:user_id) .where(created_at: from..to) .where(user: users) .sum(:amount) .tap { |h| h.default = 0 } users.each do |user| total = total_per_user[user.id] fulfill(user, total) end end end end class Payment < ActiveRecord::Base belongs_to :user validates :amount, presence: true end # Has N+1 p User.all.map { |user| user.payments.select { |payment| payment.created_at >= from && payment.created_at <= to }.sum(&:amount) } # Has no N+1 but we load too many data that we don't need p User.all.includes(:payments).map { |user| user.payments.select { |payment| payment.created_at >= from && payment.created_at <= to }.sum(&:amount) } # Has no N+1 and calculation is the most efficient p User.all.includes(:payments_total).map { |user| user.payments_total(from: from, to: to) } ``` ## Features and benefits - N1Loader doesn't use Promises which means it's easy to debug - Doesn't require injection to objects, can be used in [isolation](examples/isolated_loader.rb) - Loads data [lazily](examples/lazy_loading.rb) - Loaders can be [shared](examples/shared_loader.rb) between multiple classes - Loaded data can be [re-fetched](examples/reloading.rb) - Loader can be optimized for [single cases](examples/single_case.rb) - Loader support [arguments](examples/arguments_support.rb) - Has [integration](examples/active_record_integration.rb) with [ActiveRecord][5] which makes it brilliant - Has [integration](examples/ar_lazy_integration.rb) with [ArLazyPreload][6] which makes it excellent - Has [integration](examples/goldiloader_integration.rb) with [Goldiloader][16] which makes it excellent - Supports [context sharing](examples/n1_bind_to.rb) for plain Ruby objects without ActiveRecord ### Feature killer for [ArLazyPreload][6] and [Goldiloader][16] integration with isolated loaders In [version 1.6.0](CHANGELOG.md#160---20221019) isolated loaders were integrated with [ArLazyPreload][6] context. This means, it isn't required to inject `N1Loader` into your [ActiveRecord][5] models to avoid N+1 issues out of the box. It is especially great as many engineers are trying to avoid extra coupling between their models/services when it's possible. And this feature was designed exactly for this without losing an out of a box solution for N+1. Without further ado, please have a look at the [example](examples/ar_lazy_integration_with_isolated_loader.rb) for ArLazyPreload or the [example](examples/goldiloader_integration_with_isolated_loader.rb) for Goldiloader. _Spoiler:_ as soon as you have your loader defined, it will be as simple as `Loader.for(element)` to get your data efficiently and without N+1. ### Context sharing for plain Ruby objects with `n1_bind_to` In [version 2.1.0](CHANGELOG.md#210---20260307) context sharing was added for plain Ruby objects. This allows you to get lazy, N+1-free loading without [ActiveRecord][5] or explicit preloading. By calling `n1_bind_to(collection)` on each element, you bind them to their shared collection. When any loader is triggered on a bound element, it automatically batch-loads for the entire collection — and nested loaders propagate the context automatically as well. Have a look at the [example](examples/n1_bind_to.rb) to see how simple it is. ## Funding ### Open Collective Backers You're an individual who wants to support the project with a monthly donation. Your logo will be available on the Github page. [[Become a backer](https://opencollective.com/n1_loader#backer)] ### Open Collective Sponsors You're an organization that wants to support the project with a monthly donation. Your logo will be available on the Github page. [[Become a sponsor](https://opencollective.com/n1_loader#sponsor)] ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/djezzzl/n1_loader. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md). ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). ## Code of Conduct Everyone interacting in the N1Loader project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md). ## Changelog *N1Loader*'s changelog is available [here](CHANGELOG.md). ## Copyright Copyright (c) Evgeniy Demin. See [LICENSE.txt](LICENSE.txt) for further details. [3]: https://badge.fury.io/rb/n1_loader.svg [4]: https://badge.fury.io/rb/n1_loader [5]: https://github.com/rails/rails/tree/main/activerecord [6]: https://github.com/DmitryTsepelev/ar_lazy_preload [7]: https://stackoverflow.com/questions/97197/what-is-the-n1-selects-problem-in-orm-object-relational-mapping [8]: https://github.com/djezzzl/n1_loader [9]: https://opencollective.com/n1_loader/tiers/badge.svg [10]: https://opencollective.com/n1_loader#support [11]: https://github.com/djezzzl/n1_loader/actions/workflows/tests.yml/badge.svg?branch=master [12]: https://github.com/djezzzl/n1_loader/actions/workflows/tests.yml?query=event%3Aschedule [13]: https://github.com/djezzzl/n1_loader/actions/workflows/rubocop.yml/badge.svg?branch=master [14]: https://github.com/djezzzl/n1_loader/actions/workflows/rubocop.yml?query=event%3Aschedule [15]: https://github.com/djezzzl/n1_loader#contributing [16]: https://github.com/KentaaNL/goldiloader ================================================ FILE: Rakefile ================================================ # frozen_string_literal: true require "bundler/gem_tasks" require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) require "rubocop/rake_task" RuboCop::RakeTask.new task default: %i[spec rubocop] ================================================ FILE: activerecord-gemfiles/ar_5_latest.gemfile ================================================ # frozen_string_literal: true gem "activerecord", "~> 5" gem "concurrent-ruby", "= 1.3.4" ================================================ FILE: activerecord-gemfiles/ar_6_latest.gemfile ================================================ # frozen_string_literal: true gem "activerecord", "~> 6" gem "benchmark" gem "bigdecimal" gem "concurrent-ruby", "= 1.3.4" gem "sqlite3", "~> 1.4" ================================================ FILE: activerecord-gemfiles/ar_7_latest.gemfile ================================================ # frozen_string_literal: true gem "activerecord", "~> 7" gem "benchmark" gem "bigdecimal" gem "concurrent-ruby", "= 1.3.4" gem "sqlite3", "< 2.4.0" ================================================ FILE: activerecord-gemfiles/ar_8_latest.gemfile ================================================ # frozen_string_literal: true gem "activerecord", "~> 8" gem "benchmark" gem "bigdecimal" gem "sqlite3", "< 2.4.0" ================================================ FILE: ar_lazy_preload-gemfiles/ar_lazy_preload_0.6.1.gemfile ================================================ # frozen_string_literal: true gem "ar_lazy_preload", "= 0.6.1" ================================================ FILE: ar_lazy_preload-gemfiles/ar_lazy_preload_master.gemfile ================================================ # frozen_string_literal: true gem "ar_lazy_preload", git: "https://github.com/DmitryTsepelev/ar_lazy_preload", branch: "master" ================================================ FILE: bin/console ================================================ #!/usr/bin/env ruby # frozen_string_literal: true require "bundler/setup" require "n1_loader" # You can add fixtures and/or initialization code here to make experimenting # with your gem easier. You can also use a different console, if you like. # (If you use this, don't forget to add pry to your Gemfile!) # require "pry" # Pry.start require "irb" IRB.start(__FILE__) ================================================ FILE: bin/setup ================================================ #!/usr/bin/env bash set -euo pipefail IFS=$'\n\t' set -vx bundle install # Do any other automated setup that you need to do here ================================================ FILE: examples/active_record_integration.rb ================================================ # frozen_string_literal: true require "n1_loader/active_record" require_relative 'context/setup_database' class User < ActiveRecord::Base has_many :payments n1_optimized :payments_total do |users| total_per_user = Payment.group(:user_id).where(user: users).sum(:amount).tap { |h| h.default = 0 } users.each do |user| total = total_per_user[user.id] fulfill(user, total) end end end class Payment < ActiveRecord::Base belongs_to :user validates :amount, presence: true end fill_database # Has N+1 p User.all.map { |user| user.payments.sum(&:amount) } # Has no N+1 but we load too many data that we don't need p User.all.includes(:payments).map { |user| user.payments.sum(&:amount) } # Has no N+1 and calculation is the most efficient p User.all.includes(:payments_total).map(&:payments_total) ================================================ FILE: examples/ar_lazy_integration.rb ================================================ # frozen_string_literal: true require "n1_loader/ar_lazy_preload" require_relative 'context/setup_ar_lazy' require_relative 'context/setup_database' class User < ActiveRecord::Base has_many :payments n1_optimized :payments_total do |users| total_per_user = Payment.group(:user_id).where(user: users).sum(:amount).tap { |h| h.default = 0 } users.each do |user| total = total_per_user[user.id] fulfill(user, total) end end end class Payment < ActiveRecord::Base belongs_to :user validates :amount, presence: true end fill_database # Has N+1 p User.all.map { |user| user.payments.sum(&:amount) } # Has no N+1 and loads only required data p User.preload_associations_lazily.map(&:payments_total) # or ArLazyPreload.config.auto_preload = true User.all.map(&:payments_total) ================================================ FILE: examples/ar_lazy_integration_with_isolated_loader.rb ================================================ # frozen_string_literal: true require "n1_loader/ar_lazy_preload" require_relative 'context/setup_ar_lazy' require_relative 'context/setup_database' class Loader < N1Loader::Loader def perform(users) total_per_user = Payment.group(:user_id).where(user: users).sum(:amount).tap { |h| h.default = 0 } users.each do |user| total = total_per_user[user.id] fulfill(user, total) end end end class User < ActiveRecord::Base has_many :payments end class Payment < ActiveRecord::Base belongs_to :user validates :amount, presence: true end fill_database # Has N+1 and loads redundant data p User.all.map { |user| user.payments.sum(&:amount) } # Has no N+1 and loads only required data p User.preload_associations_lazily.all.map { |user| Loader.for(user) } # or ArLazyPreload.config.auto_preload = true p User.all.map { |user| Loader.for(user) } ================================================ FILE: examples/arguments_support.rb ================================================ # frozen_string_literal: true require "n1_loader/active_record" require_relative 'context/setup_database' class User < ActiveRecord::Base has_many :payments n1_optimized :payments_total do # Arguments can be: # argument :something, optional: true # argument :something, default: -> { 100 } # # Note: do not use mutable (mostly timing related) defaults like: # argument :from, default -> { 2.minutes.from_now } # because such values will be unique for every loader call which will make N+1 issue stay argument :from argument :to # This is used to define logic how loaders are compared to each other # default is: # cache_key { *arguments.map(&:object_id) } cache_key { [from, to] } def perform(users) total_per_user = Payment .group(:user_id) .where(created_at: from..to) .where(user: users) .sum(:amount) .tap { |h| h.default = 0 } users.each do |user| total = total_per_user[user.id] fulfill(user, total) end end end end class Payment < ActiveRecord::Base belongs_to :user validates :amount, presence: true end fill_database from = 2.days.ago to = 1.day.ago # Has N+1 p User.all.map { |user| user.payments.select do |payment| payment.created_at >= from && payment.created_at <= to end.sum(&:amount) } # Has no N+1 but we load too many data that we don't need p User.all.includes(:payments).map { |user| user.payments.select do |payment| payment.created_at >= from && payment.created_at <= to end.sum(&:amount) } # Has no N+1 and calculation is the most efficient p User.all.includes(:payments_total).map { |user| user.payments_total(from: from, to: to) } ================================================ FILE: examples/context/service.rb ================================================ # 3rd party service, or database, or anything else that can perform in batches class Service def self.count @count ||= 0 end def self.increase! @count = (@count || 0) + 1 end def self.receive(*users) increase! users.flatten.map(&:object_id) end def self.single(user) user.object_id end end ================================================ FILE: examples/context/setup_ar_lazy.rb ================================================ ActiveSupport.on_load(:active_record) do ActiveRecord::Base.include(ArLazyPreload::Base) ActiveRecord::Relation.prepend(ArLazyPreload::Relation) ActiveRecord::AssociationRelation.prepend(ArLazyPreload::AssociationRelation) ActiveRecord::Relation::Merger.prepend(ArLazyPreload::Merger) [ ActiveRecord::Associations::CollectionAssociation, ActiveRecord::Associations::Association ].each { |klass| klass.prepend(ArLazyPreload::Association) } ActiveRecord::Associations::CollectionAssociation.prepend(ArLazyPreload::CollectionAssociation) ActiveRecord::Associations::CollectionProxy.prepend(ArLazyPreload::CollectionProxy) end ================================================ FILE: examples/context/setup_database.rb ================================================ require "sqlite3" ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") ActiveRecord::Base.connection.tables.each do |table| ActiveRecord::Base.connection.drop_table(table, force: :cascade) end ActiveRecord::Schema.verbose = false ActiveRecord::Base.logger = Logger.new($stdout) ActiveRecord::Schema.define(version: 1) do create_table(:payments) do |t| t.belongs_to :user t.integer :amount t.timestamps end create_table(:users) end def fill_database 10.times do user = User.create! 10.times do Payment.create!(user: user, amount: rand(1000)) end end end ================================================ FILE: examples/core.rb ================================================ # frozen_string_literal: true require "n1_loader" require_relative 'context/service' # Class that wants to request 3rd party service without N+1 class User include N1Loader::Loadable def unoptimized_call Service.receive(self)[0] end n1_optimized :optimized_call do |users| data = Service.receive(users) users.each_with_index do |user, index| fulfill(user, data[index]) end end end # works fine for single case user = User.new p "Works correctly: #{user.unoptimized_call == user.optimized_call}" users = [User.new, User.new] # Has N+1 count_before = Service.count p users.map(&:unoptimized_call) p "Has N+1 #{Service.count == count_before + users.count}" # Has no N+1 via explicit preloading count_before = Service.count N1Loader::Preloader.new(users).preload(:optimized_call) p users.map(&:optimized_call) p "Has no N+1: #{Service.count == count_before + 1}" users = [User.new, User.new] # Has no N+1 via n1_bind_to context sharing (see examples/n1_bind_to.rb) users.each { |user| user.n1_bind_to(users) } count_before = Service.count p users.map(&:optimized_call) p "Has no N+1: #{Service.count == count_before + 1}" ================================================ FILE: examples/goldiloader_integration.rb ================================================ # frozen_string_literal: true require "n1_loader/goldiloader" require_relative "context/setup_database" class User < ActiveRecord::Base has_many :payments n1_optimized :payments_total do |users| total_per_user = Payment.group(:user_id).where(user: users).sum(:amount).tap { |h| h.default = 0 } users.each do |user| total = total_per_user[user.id] fulfill(user, total) end end end class Payment < ActiveRecord::Base belongs_to :user validates :amount, presence: true end fill_database # Has N+1 p(User.all.map { |user| user.payments.sum(&:amount) }) # Has no N+1 and loads only required data (Goldiloader auto-batches by default) p User.all.map(&:payments_total) ================================================ FILE: examples/goldiloader_integration_with_isolated_loader.rb ================================================ # frozen_string_literal: true require "n1_loader/goldiloader" require_relative "context/setup_database" class Loader < N1Loader::Loader def perform(users) total_per_user = Payment.group(:user_id).where(user: users).sum(:amount).tap { |h| h.default = 0 } users.each do |user| total = total_per_user[user.id] fulfill(user, total) end end end class User < ActiveRecord::Base has_many :payments end class Payment < ActiveRecord::Base belongs_to :user validates :amount, presence: true end fill_database # Has N+1 and loads redundant data p(User.all.map { |user| user.payments.sum(&:amount) }) # Has no N+1 and loads only required data (Goldiloader auto-batches by default) p(User.all.map { |user| Loader.for(user) }) ================================================ FILE: examples/graphql.rb ================================================ # frozen_string_literal: true require "n1_loader/ar_lazy_preload" require 'graphql' require_relative 'context/setup_database' require_relative 'context/setup_ar_lazy' class User < ActiveRecord::Base has_many :payments n1_optimized :payments_total do |users| total_per_user = Payment.group(:user_id).where(user: users).sum(:amount).tap { |h| h.default = 0 } users.each do |user| total = total_per_user[user.id] fulfill(user, total) end end end class Payment < ActiveRecord::Base belongs_to :user validates :amount, presence: true end 10.times do user = User.create! 10.times do Payment.create!(user: user, amount: rand(1000)) end end ArLazyPreload.config.auto_preload = true # Or use +preload_associations_lazily+ when loading objects from database class UserType < GraphQL::Schema::Object field :payments_total, Integer end class QueryType < GraphQL::Schema::Object field :users, [UserType] def users User.all end end class Schema < GraphQL::Schema query QueryType end query_string = <<~GQL { users { paymentsTotal } } GQL # No N+1. And never will be! p Schema.execute(query_string)['data'] ================================================ FILE: examples/isolated_loader.rb ================================================ require 'n1_loader' class IsolatedLoader < N1Loader::Loader def perform(elements) elements.each { |element| fulfill(element, [element]) } end end objects = [1, 2, 3, 4] loader = IsolatedLoader.new(objects) objects.each do |object| loader.for(object) # => it has no N+1 and it doesn't require to be injected in the class end ================================================ FILE: examples/lazy_loading.rb ================================================ require 'n1_loader' require_relative 'context/service' # Class that wants to request 3rd party service without N+1 class User include N1Loader::Loadable n1_optimized :optimized_call do |users| data = Service.receive(users) users.each_with_index do |user, index| fulfill(user, data[index]) end end end users = [User.new, User.new, User.new] # Initialized loader but didn't perform it yet N1Loader::Preloader.new(users).preload(:optimized_call) p "No calls yet: #{Service.count == 0}" # First time loading users.map(&:optimized_call) p "First time loaded: #{Service.count == 1}" ================================================ FILE: examples/n1_bind_to.rb ================================================ # frozen_string_literal: true require "n1_loader" require_relative 'context/service' # Class that wants to request 3rd party service without N+1 class User include N1Loader::Loadable n1_optimized :optimized_call do |users| data = Service.receive(users) users.each_with_index do |user, index| fulfill(user, data[index]) end end end users = [User.new, User.new, User.new] # Without n1_bind_to: each user lazily loads independently causing N+1 count_before = Service.count p users.map(&:optimized_call) p "Has N+1: #{Service.count == count_before + users.count}" users = [User.new, User.new, User.new] # With n1_bind_to: bind users to the collection so lazy loading is automatically batched users.each { |user| user.n1_bind_to(users) } count_before = Service.count p users.map(&:optimized_call) p "Has no N+1: #{Service.count == count_before + 1}" ================================================ FILE: examples/reloading.rb ================================================ require 'n1_loader' require_relative 'context/service' class User include N1Loader::Loadable n1_optimized :optimized_call do |users| data = Service.receive(users) users.each_with_index do |user, index| fulfill(user, data[index]) end end end users = [User.new, User.new, User.new] # Initialized loader but didn't perform it yet N1Loader::Preloader.new(users).preload(:optimized_call) p "No calls yet: #{Service.count == 0}" # First time loading users.map(&:optimized_call) p "First time loaded: #{Service.count == 1}" users.first.optimized_call(reload: true) p "Reloaded for this object only: #{Service.count == 2}" users.first.n1_clear_cache users.first.optimized_call p "Reloaded for this object only: #{Service.count == 3}" ================================================ FILE: examples/shared_loader.rb ================================================ require 'n1_loader' require_relative 'context/service' # Loader that will be shared between multiple classes class SharedLoader < N1Loader::Loader def perform(objects) data = Service.receive(objects) objects.each_with_index do |user, index| fulfill(user, data[index]) end end end class User include N1Loader::Loadable n1_optimized :optimized_call, SharedLoader end class Payment include N1Loader::Loadable n1_optimized :optimized_call, SharedLoader end objects = [User.new, Payment.new, User.new, Payment.new] N1Loader::Preloader.new(objects).preload(:optimized_call) # First time loading for all objects objects.map(&:optimized_call) p "Loaded for all once: #{Service.count == 1}" ================================================ FILE: examples/single_case.rb ================================================ require 'n1_loader' require_relative 'context/service' # Loader that will be shared between multiple classes class OptimizedLoader < N1Loader::Loader def perform(objects) data = Service.receive(objects) objects.each_with_index do |user, index| fulfill(user, data[index]) end end def single(object) Service.single(object) end end class User include N1Loader::Loadable n1_optimized :optimized_call, OptimizedLoader end objects = [User.new, User.new] N1Loader::Preloader.new(objects).preload(:optimized_call) objects.map(&:optimized_call) p "Used multi-case perform: #{Service.count == 1}" User.new.optimized_call p "Used single-case perform: #{Service.count == 1}" ================================================ FILE: goldiloader-gemfiles/goldiloader.gemfile ================================================ # frozen_string_literal: true gem "goldiloader" ================================================ FILE: guides/enhanced-activerecord.md ================================================ # Enhanced ActiveRecord - Do you like `ActiveRecord` preloading? - How many times have you resolved your N+1 issues with `includes` or `preload`? - Do you know that preloading has limitations? In this guide, I'd like to share with you tips and tricks about ActiveRecord preloading and how you can enhance it to the next level. Let's start by describing the models. ```ruby # The model represents users in our application. class User < ActiveRecord::Base # Every user may have from 0 to many payments. has_many :payments end # The model represents payments in our application. class Payment < ActiveRecord::Base # Every payment belongs to a user. belongs_to :user end ``` Assuming we want to iterate over a group of users and check how many payments they have, we may do: ```ruby # The query we want to use to fetch users from the database. users = User.all # Iteration over selected users. users.each do |user| # Print amount of user's payments. # This query will be called for every user, bringing an N+1 issue. p user.payments.count end ``` We can fix the N+1 issue above in a second. We need to add ActiveRecord's `includes` to the query that fetches users. ```ruby # The query to fetch users with preload payments for every selected user. users = User.includes(:payments).all ``` Then, we can iterate over the group again without the N+1 issue. ```ruby users.each do |user| p user.payments.count end ``` Experienced with ActiveRecord person may notice that the iteration above still will have an N+1 issue. The reason is the `.count` method and its behavior. This issue brings us to the first tip. ### Tip 1. `count` vs `size` vs `length` - `count` - always queries the database with `COUNT` query; - `size` - queries the database with `COUNT` only when there is no preloaded data, returns array length otherwise; - `length` - always returns array length, in case there is no data, load it first. _Note:_ be careful with `size` as ordering is critical. Meaning, for `user = User.first` ```ruby # Does `COUNT` query user.payments.size # Does `SELECT` query user.payments.each { |payment| } ``` is different from ```ruby # Does `SELECT` query user.payments.each { |payment| } # No query user.payments.size ``` You may notice that the above solution loads all payment information when the amount is only needed. There is a well-known solution for this case called [counter_cache](https://guides.rubyonrails.org/association_basics.html#options-for-belongs-to-counter-cache). To use that, you need to add `payments_count` field to `users` table and adjust `Payment` model. ```ruby # Migration to add `payments_count` to `users` table. class AddPaymentsCountToUsers < ActiveRecord::Migration def change add_column :users, :payments_count, :integer, default: 0, null: false end end # Change `belongs_to` to have `counter_cache` option. class Payment < ActiveRecord::Base belongs_to :user, counter_cache: true end ``` _Note:_ avoid adding or removing payments from the database directly or through `insert_all`/`delete`/`delete_all` as `counter_cache` is using ActiveRecord callbacks to update the field's value. It's worth mentioning [counter_culture](https://github.com/magnusvk/counter_culture) alternative that has many features compared with the built-in `counter_cache` ## Associations with arguments Now, let's assume we want to fetch the number of payments in a time frame for every user in a group. ```ruby from = 1.months.ago to = Time.current # Query to fetch users. users = User.all users.each do |user| # Print the number of payments in a time frame for every user. # Database query will be triggered for every user, meaning it has an N+1 issue. p user.payments.where(created_at: from...to).count end ``` ActiveRecord supports defining associations with arguments. ```ruby class User < ActiveRecord::Base has_many :payments, -> (from, to) { where(created_at: from...to) } end ``` Unfortunately, such associations are not possible to preload with `includes`. Gladly, there is a solution with [N1Loader](https://github.com/djezzzl/n1_loader/). ```ruby # Install gem dependencies. require 'n1_loader/active_record' class User < ActiveRecord::Base n1_optimized :payments_count do argument :from argument :to def perform(users) # Fetch the payment number once for all users. payments = Payment.where(user: users).where(created_at: from...to).group(:user_id).count users.each do |user| # Assign preloaded data to every user. # Note: it doesn't use any promises. fulfill(user, payments[user.id]) end end end end from = 1.month.ago to = Time.current # Preload `payments` N1Loader "association". Doesn't query the database yet. users = User.includes(:payments_count).all users.each do |user| # Queries the database once, meaning has no N+1 issues. p user.payments_count(from, to) end ``` Let's look at another example. Assuming we want to fetch the last payment for every user. We can try to define scoped `has_one` association and use that. ```ruby class User < ActiveRecord::Base has_one :last_payment, -> { order(id: :desc) }, class_name: 'Payment' end ``` We can see that preloading is working. ```ruby users = User.includes(:last_payment) users.each do |user| # No N+1. Last payment was returned. p user.last_payment end ``` At first glance, we may think everything is alright. Unfortunately, it is not. ### Tip 2. Enforce `has_one` associations on the database level ActiveRecord, fetches all available payments for every user with provided order and then assigns only first payment to the association. First, such querying is inefficient as we load many redundant information. But most importantly, this association may lead to big issues. Other engineers may use it, for example, for `joins(:last_payment)`. Assuming that association has strict agreement on the database level that a user may have none or a single payment in the database. Apparently, it may not be the case, and some queries will return unexpected data. Described issues may be found with [DatabaseConsistency](https://github.com/djezzzl/database_consistency). Back to the task, we can solve it with [N1Loader](https://github.com/djezzzl/n1_loader) in the following way ```ruby require 'n1_loader/active_record' class User < ActiveRecord::Base n1_optimized :last_payment do |users| subquery = Payment.select('MAX(id)').where(user: users) payments = Payment.where(id: subquery).index_by(&:user_id) users.each do |user| fulfill(user, payments[user.id]) end end end users = User.includes(:last_payment).all users.each do |user| # Queries the database once, meaning no N+1. p user.last_payment end ``` Attentive reader could notice that in every described case, it was a requirement to explicitly list data that we want to preload for a group of users. Gladly, there is a simple solution! [ArLazyPreload](https://github.com/DmitryTsepelev/ar_lazy_preload) will make N+1 disappear just by enabling it. As soon as you need to load association for any record, it will load it once for all records that were fetched along this one. And it works with ActiveRecord and N1Loader perfectly! Let's look at the example. ```ruby # Require N1Loader with ArLazyPreload integration require 'n1_loader/ar_lazy_preload' # Enable ArLazyPreload globally, so you don't need to care about `includes` anymore ArLazyPreload.config.auto_preload = true class User < ActiveRecord::Base has_many :payments n1_optimized :last_payment do |users| subquery = Payment.select('MAX(id)').where(user: users) payments = Payment.where(id: subquery).index_by(&:user_id) users.each do |user| fulfill(user, payments[user.id]) end end end # no need to specify `includes` users = User.all users.each do |user| p user.payments # no N+1 p user.last_payment # no N+1 end ``` As you can see, there is no need to even remember about resolving N+1 when you have both [ArLazyPreload](https://github.com/DmitryTsepelev/ar_lazy_preload) and [N1Loader](https://github.com/djezzzl/n1_loader) in your pocket. It works great with GraphQL API too. Give it and try and share your feedback! ================================================ FILE: lib/n1_loader/active_record/associations_preloader.rb ================================================ # frozen_string_literal: true module N1Loader module ActiveRecord module Associations module Preloader # :nodoc: N1LoaderReflection = Struct.new(:name, :loader) do def options {} end def deprecated? false end def through_reflection? false end end def preloaders_for_reflection(reflection, records) return super unless reflection.is_a?(N1LoaderReflection) N1Loader::Preloader.new(records).preload(reflection.name) end def grouped_records # rubocop:disable Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize n1_load_records, records = source_records.partition do |record| record.class.respond_to?(:n1_loaders) && record.class.n1_loaders[association] end h = n1_load_records.group_by do |record| N1LoaderReflection.new(association, record.class.n1_loaders[association]) end polymorphic_parent = !root? && parent.polymorphic? records.each do |record| reflection = record.class._reflect_on_association(association) next if polymorphic_parent && !reflection || !record.association(association).klass (h[reflection] ||= []) << record end h end end end end end ================================================ FILE: lib/n1_loader/active_record/associations_preloader_v5.rb ================================================ # frozen_string_literal: true module N1Loader module ActiveRecord module Associations module Preloader # :nodoc: N1LoaderReflection = Struct.new(:name, :loader) do def options {} end end def preloaders_for_one(association, records, scope) grouped_records(association, records).flat_map do |reflection, klasses| next N1Loader::Preloader.new(records).preload(reflection.name) if reflection.is_a?(N1LoaderReflection) klasses.map do |rhs_klass, rs| loader = preloader_for(reflection, rs).new(rhs_klass, rs, reflection, scope) loader.run self loader end end end def grouped_records(association, records) n1_load_records, records = records.partition do |record| record.class.respond_to?(:n1_loaders) && record.class.n1_loaders.key?(association) end hash = n1_load_records.group_by do |record| N1LoaderReflection.new(association, record.class.n1_loaders[association]) end hash.merge(super) end end end end end ================================================ FILE: lib/n1_loader/active_record/associations_preloader_v6.rb ================================================ # frozen_string_literal: true module N1Loader module ActiveRecord module Associations module Preloader # :nodoc: N1LoaderReflection = Struct.new(:name, :loader) do def options {} end end def preloaders_for_reflection(reflection, records, scope) return super unless reflection.is_a?(N1LoaderReflection) N1Loader::Preloader.new(records).preload(reflection.name) end def grouped_records(association, records, polymorphic_parent) n1_load_records, records = records.partition do |record| record.class.respond_to?(:n1_loaders) && record.class.n1_loaders[association] end hash = n1_load_records.group_by do |record| N1LoaderReflection.new(association, record.class.n1_loaders[association]) end hash.merge(super) end end end end end ================================================ FILE: lib/n1_loader/active_record/base.rb ================================================ # frozen_string_literal: true module N1Loader module ActiveRecord # Extension module for ActiveRecord::Base module Base extend ActiveSupport::Concern include N1Loader::Loadable # Clear N1Loader cache on reloading the object def reload(*) n1_clear_cache super end end end end ================================================ FILE: lib/n1_loader/active_record/loader.rb ================================================ # frozen_string_literal: true N1Loader::Loader.define_method :preloaded_records do @preloaded_records ||= loaded? && loaded_by_value.values.flatten end ================================================ FILE: lib/n1_loader/active_record/loader_collection.rb ================================================ # frozen_string_literal: true N1Loader::LoaderCollection.define_method :preloaded_records do raise N1Loader::ActiveRecord::InvalidPreloading, "Cannot preload loader with arguments" if loader_class.arguments with.preloaded_records end N1Loader::LoaderCollection.define_method :runnable_loaders do [self] end N1Loader::LoaderCollection.define_method :run? do true end N1Loader::LoaderCollection.define_method :future_classes do [] end ================================================ FILE: lib/n1_loader/active_record.rb ================================================ # frozen_string_literal: true # Load core library require_relative "../n1_loader" # Load integration dependency require "active_record" module N1Loader module ActiveRecord class InvalidPreloading < N1Loader::Error; end end end # Library integration ActiveSupport.on_load(:active_record) do require_relative "active_record/loader" require_relative "active_record/loader_collection" require_relative "active_record/base" case ActiveRecord::VERSION::MAJOR when 6 require_relative "active_record/associations_preloader_v6" ActiveRecord::Associations::Preloader.prepend(N1Loader::ActiveRecord::Associations::Preloader) when 5 require_relative "active_record/associations_preloader_v5" ActiveRecord::Associations::Preloader.prepend(N1Loader::ActiveRecord::Associations::Preloader) else require_relative "active_record/associations_preloader" ActiveRecord::Associations::Preloader::Branch.prepend(N1Loader::ActiveRecord::Associations::Preloader) end ActiveRecord::Base.include(N1Loader::ActiveRecord::Base) end ================================================ FILE: lib/n1_loader/ar_lazy_preload/associated_context_builder.rb ================================================ # frozen_string_literal: true module N1Loader module ArLazyPreload # Context builder for N1Loader class AssociatedContextBuilder < ::ArLazyPreload::AssociatedContextBuilder attr_reader :records def initialize(parent_context:, association_name:, records:) super(parent_context: parent_context, association_name: association_name) @records = records end def perform ::ArLazyPreload::Context.register( records: records.flatten(1).select { |record| record.respond_to?(:lazy_preload_context=) }, association_tree: child_association_tree, auto_preload: parent_context.auto_preload? ) end end end end ================================================ FILE: lib/n1_loader/ar_lazy_preload/context.rb ================================================ # frozen_string_literal: true # Returns cached N1Loader::LoaderCollection from context for a loader. # In case there is none yet, saves passed block to a cache. ArLazyPreload::Contexts::BaseContext.define_method :fetch_n1_loader_collection do |loader, &block| (@n1_loader_collections ||= {})[loader] ||= block.call end ================================================ FILE: lib/n1_loader/ar_lazy_preload/context_adapter.rb ================================================ # frozen_string_literal: true module N1Loader module ArLazyPreload # Context adapter for injected N1Loader loaders. class ContextAdapter attr_reader :context delegate_missing_to :context def initialize(context) @context = context end # Assign initialized preloader to +association_name+ in case it wasn't yet preloaded within the given context. def try_preload_lazily(association_name) return unless context&.send(:association_needs_preload?, association_name) perform_preloading(association_name) end # Initialize preloader for +association_name+ with context builder callback. # The callback will be executed when on records load. def perform_preloading(association_name) context_setup = lambda { |records| AssociatedContextBuilder.prepare( parent_context: self, association_name: association_name, records: records ) } N1Loader::Preloader.new(records, context_setup).preload(association_name) end end end end ================================================ FILE: lib/n1_loader/ar_lazy_preload/loadable.rb ================================================ # frozen_string_literal: true module N1Loader module ArLazyPreload module Loadable # :nodoc: def n1_loader(name) return n1_loaders[name] if n1_loaders[name] ContextAdapter.new(lazy_preload_context).try_preload_lazily(name) if respond_to?(:lazy_preload_context) super end end end end ================================================ FILE: lib/n1_loader/ar_lazy_preload/loader.rb ================================================ # frozen_string_literal: true # Raised when a single object without ArLazyPreload context support was passed to an isolated loader. N1Loader::Loader::UnsupportedArLazyPreload = Class.new(StandardError) # Defines a singleton method method that allows isolated loaders # to use ArLazyPreload context without passing sibling records. N1Loader::Loader.define_singleton_method(:for) do |element, **args| # It is required to have an ArLazyPreload context supported raise N1Loader::Loader::UnsupportedArLazyPreload unless element.respond_to?(:lazy_preload_context) if element.lazy_preload_context.nil? ArLazyPreload::Context.register( records: [element], association_tree: [], auto_preload: true ) end # Fetch or initialize loader from ArLazyPreload context loader_collection = element.lazy_preload_context.fetch_n1_loader_collection(self) do context_setup = lambda { |records| N1Loader::ArLazyPreload::AssociatedContextBuilder.prepare( parent_context: element.lazy_preload_context, association_name: "cached_n1_loader_collection_#{self}".downcase.to_sym, records: records ) } N1Loader::LoaderCollection.new(self, element.lazy_preload_context.records).tap do |collection| collection.context_setup = context_setup end end # Fetch value from loader loader_collection.with(**args).for(element) end ================================================ FILE: lib/n1_loader/ar_lazy_preload/loader_collection_patch.rb ================================================ # frozen_string_literal: true module N1Loader module ArLazyPreload # A patch to {N1Loader::LoaderCollection} to setup lazy context lazily. module LoaderCollectionPatch attr_accessor :context_setup def with(**args) result = super result.context_setup = context_setup if context_setup && result.context_setup.nil? result end end end end ================================================ FILE: lib/n1_loader/ar_lazy_preload/loader_patch.rb ================================================ # frozen_string_literal: true module N1Loader module ArLazyPreload # A patch to {N1Loader::Loader} to setup lazy context lazily. module LoaderPatch attr_accessor :context_setup def loaded? return true if @already_loaded && @already_context super synchronize { non_thread_safe_context_setting unless @already_context } true end def non_thread_safe_context_setting return if @already_context context_setup&.call(loaded_by_identity.values.flatten) @already_context = true end end end end ================================================ FILE: lib/n1_loader/ar_lazy_preload/preloader_patch.rb ================================================ # frozen_string_literal: true module N1Loader module ArLazyPreload # A patch to {N1Loader::Preloader} setup lazy context lazily. module PreloaderPatch def initialize(elements, context_setup = nil) super(elements) @context_setup = context_setup end def preload(*keys) super.each do |loader_collection| loader_collection.context_setup = context_setup end end private attr_reader :context_setup end end end ================================================ FILE: lib/n1_loader/ar_lazy_preload.rb ================================================ # frozen_string_literal: true # Load core library require_relative "active_record" # Load integration dependency require "rails" require "ar_lazy_preload" # Library integration require_relative "ar_lazy_preload/loadable" require_relative "ar_lazy_preload/context_adapter" require_relative "ar_lazy_preload/associated_context_builder" require_relative "ar_lazy_preload/loader_collection_patch" require_relative "ar_lazy_preload/preloader_patch" require_relative "ar_lazy_preload/loader_patch" require_relative "ar_lazy_preload/loader" require_relative "ar_lazy_preload/context" N1Loader::Loadable.prepend(N1Loader::ArLazyPreload::Loadable) N1Loader::Preloader.prepend(N1Loader::ArLazyPreload::PreloaderPatch) N1Loader::Loader.prepend(N1Loader::ArLazyPreload::LoaderPatch) N1Loader::LoaderCollection.prepend(N1Loader::ArLazyPreload::LoaderCollectionPatch) ================================================ FILE: lib/n1_loader/core/loadable.rb ================================================ # frozen_string_literal: true module N1Loader # The module to be included to the class to define associated loaders. # # class Example # include N1Loader::Loadable # # # with inline loader # n1_optimized :something do # def perform(elements) # elements.each { |element| fulfill(element, element.calculate_something) } # end # end # # # with custom loader # n1_optimized :something, MyLoader # end # # # custom loader # class MyLoader < N1Loader::Loader # def perform(elements) # elements.each { |element| fulfill(element, element.calculate_something) } # end # end module Loadable def n1_loaders @n1_loaders ||= {} end def n1_loader(name) n1_loaders[name] end def n1_bind_to(collection) unless collection.is_a?(Array) && collection.any? do |obj| obj == self || obj.equal?(self) end raise InvalidBinding, "assigned collection should be array and include object" end @n1_binding = collection end def n1_bind_to? !@n1_binding.nil? end def n1_loader_reload(name) elements = @n1_binding || [self] collection = LoaderCollection.new(self.class.n1_loaders[name], elements) @n1_binding&.each { |el| el.n1_loaders[name] = collection if el.respond_to?(:n1_loaders) } n1_loaders[name] = collection end def n1_clear_cache @n1_binding = nil self.class.n1_loaders.each_key do |name| n1_loaders[name] = nil end end def self.included(base) base.extend(ClassMethods) end module ClassMethods # :nodoc: def n1_loaders @n1_loaders ||= superclass.respond_to?(:n1_loaders) ? superclass.n1_loaders.dup : {} end def n1_optimized(name, loader = nil, &block) loader ||= LoaderBuilder.build(&block) n1_loaders[name] = loader define_method(name) do |reload: false, **args| n1_loader_reload(name) if reload || n1_loader(name).nil? n1_loader(name).with(**args).for(self) end end end end end ================================================ FILE: lib/n1_loader/core/loader.rb ================================================ # frozen_string_literal: true module N1Loader # Loader that performs the loading. # # Subclasses must define +perform+ method that accepts single argument # and returns hash where key is the element and value is what we want to load. class Loader prepend MonitorMixin class << self attr_reader :arguments # Defines an argument that can be accessed within the loader. # # First defined argument will have the value of first passed argument, # meaning the order is important. # # @param name [Symbol] # @param opts [Hash] # @option opts [Boolean] optional false by default # @option opts [Proc] default def argument(name, **opts) opts[:optional] = true if opts[:default] @arguments ||= [] define_method(name) do args.fetch(name) { args[name] = opts[:default]&.call } end @arguments << opts.merge(name: name) end # Defines a custom cache key that is calculated for passed arguments. def cache_key(&block) define_method(:cache_key) do check_arguments! instance_exec(&block) end end end def initialize(elements, **args) @elements = elements @args = args end def for(element) return unless loaded? if loaded_by_identity.empty? && elements.any? raise NotFilled, "Nothing was preloaded, perhaps you forgot to use fulfill method" end return loaded_by_identity[element] if loaded_by_identity.key?(element) return loaded_by_value[element] if loaded_by_value.key?(element) raise NotLoaded, "The data was not preloaded for the given element" end def cache_key check_arguments! args.values.map(&:object_id) end private attr_reader :elements, :args, :loaded_by_value, :loaded_by_identity def check_missing_arguments! return unless (arguments = self.class.arguments) required_arguments = required_arguments(arguments) return if required_arguments.all? { |argument| args.key?(argument) } missing_arguments = required_arguments.reject { |argument| args.key?(argument) } list = missing_arguments.map { |argument| ":#{argument}" }.join(", ") raise MissingArgument, "Loader requires [#{list}] arguments but they are missing" end def required_arguments(args) args.reject { |argument| argument[:optional] } .map { |argument| argument[:name] } end def check_arguments! check_missing_arguments! check_invalid_arguments! end def check_invalid_arguments! return unless (arguments = self.class.arguments) args.each_key do |arg| next if arguments.find { |argument| argument[:name] == arg } raise InvalidArgument, "Loader doesn't define #{arg} argument" end end def perform(_elements) raise NotImplemented, "Subclasses have to implement the method" end def fulfill(element, value) loaded_by_identity[element] = value loaded_by_value[element] = value end def loaded? return true if @already_loaded synchronize { non_thread_safe_loading unless @already_loaded } true end def non_thread_safe_loading # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity return if @already_loaded check_arguments! @loaded_by_identity = {}.compare_by_identity @loaded_by_value = {} if respond_to?(:single) && elements.size == 1 fulfill(elements.first, single(elements.first)) elsif elements.any? perform(elements) # propagate context to loaded objects only when it was set if elements.first.respond_to?(:n1_bind_to?) && elements.first.n1_bind_to? loaded_objects = loaded_by_identity.values.flatten loaded_objects.each { |el| el.n1_bind_to(loaded_objects) if el.respond_to?(:n1_bind_to) } end end @already_loaded = true end end end ================================================ FILE: lib/n1_loader/core/loader_builder.rb ================================================ # frozen_string_literal: true module N1Loader # The class builds {N1Loader::Loader} class LoaderBuilder def self.build(&block) Class.new(N1Loader::Loader) do if block.arity == 1 define_method(:perform, &block) else class_eval(&block) end end end end end ================================================ FILE: lib/n1_loader/core/loader_collection.rb ================================================ # frozen_string_literal: true module N1Loader # The class is used for storing collections of loaders for elements per set of arguments. class LoaderCollection attr_reader :loader_class, :elements def initialize(loader_class, elements) @loader_class = loader_class @elements = elements end def with(**args) loader = loader_class.new(elements, **args) loaders[loader.cache_key] ||= loader end private def loaders @loaders ||= {} end end end ================================================ FILE: lib/n1_loader/core/preloader.rb ================================================ # frozen_string_literal: true module N1Loader # Preloader that lazily preloads data to every element. # # It supports multiple keys. # # It supports elements that have different loaders under the same key. # It will properly preload data to each of the element of the similar group. class Preloader attr_reader :elements def initialize(elements) @elements = elements end def preload(*keys) keys.flatten(1).flat_map do |key| elements .group_by { |element| loader_class(element, key) } .select { |loader_class, _| loader_class } .map do |(loader_class, grouped_elements)| loader_collection = N1Loader::LoaderCollection.new(loader_class, grouped_elements) grouped_elements.each { |grouped_element| grouped_element.n1_loaders[key] = loader_collection } loader_collection end end end private def loader_class(element, key) element.class.respond_to?(:n1_loaders) && element.class.n1_loaders[key] end end end ================================================ FILE: lib/n1_loader/goldiloader/context.rb ================================================ # frozen_string_literal: true # Returns cached N1Loader::LoaderCollection from context for a loader. # In case there is none yet, saves passed block to a cache. Goldiloader::AutoIncludeContext.define_method :fetch_n1_loader_collection do |loader, &block| (@n1_loader_collections ||= {})[loader] ||= block.call end ================================================ FILE: lib/n1_loader/goldiloader/context_adapter.rb ================================================ # frozen_string_literal: true module N1Loader module Goldiloader # Context adapter for injected N1Loader loaders. class ContextAdapter attr_reader :context def initialize(context) @context = context end # Trigger preloading for +association_name+ across all models in the context. def try_preload_lazily(association_name) perform_preloading(association_name) if context end # Initialize preloader for +association_name+ with context builder callback. # The callback will be executed when records are loaded. def perform_preloading(association_name) context_setup = lambda { |records| ar_records = records.flatten(1).select { |record| record.respond_to?(:auto_include_context=) } ::Goldiloader::AutoIncludeContext.register_models(ar_records) unless ar_records.empty? } N1Loader::Preloader.new(context.models, context_setup).preload(association_name) end end end end ================================================ FILE: lib/n1_loader/goldiloader/loadable.rb ================================================ # frozen_string_literal: true module N1Loader module Goldiloader module Loadable # :nodoc: def n1_loader(name) return n1_loaders[name] if n1_loaders[name] ContextAdapter.new(auto_include_context).try_preload_lazily(name) if respond_to?(:auto_include_context) super end end end end ================================================ FILE: lib/n1_loader/goldiloader/loader.rb ================================================ # frozen_string_literal: true # Raised when a single object without Goldiloader context support was passed to an isolated loader. N1Loader::Loader::UnsupportedGoldiloader = Class.new(StandardError) # Defines a singleton method that allows isolated loaders # to use Goldiloader context without passing sibling records. N1Loader::Loader.define_singleton_method(:for) do |element, **args| # It is required to have a Goldiloader context supported raise N1Loader::Loader::UnsupportedGoldiloader unless element.respond_to?(:auto_include_context) context = element.auto_include_context # Fetch or initialize loader from Goldiloader context loader_collection = context.fetch_n1_loader_collection(self) do context_setup = lambda { |records| ar_records = records.flatten(1).select { |record| record.respond_to?(:auto_include_context=) } ::Goldiloader::AutoIncludeContext.register_models(ar_records) unless ar_records.empty? } N1Loader::LoaderCollection.new(self, context.models).tap do |collection| collection.context_setup = context_setup end end # Fetch value from loader loader_collection.with(**args).for(element) end ================================================ FILE: lib/n1_loader/goldiloader/loader_collection_patch.rb ================================================ # frozen_string_literal: true module N1Loader module Goldiloader # A patch to {N1Loader::LoaderCollection} to setup lazy context lazily. module LoaderCollectionPatch attr_accessor :context_setup def with(**args) result = super result.context_setup = context_setup if context_setup && result.context_setup.nil? result end end end end ================================================ FILE: lib/n1_loader/goldiloader/loader_patch.rb ================================================ # frozen_string_literal: true module N1Loader module Goldiloader # A patch to {N1Loader::Loader} to setup lazy context lazily. module LoaderPatch attr_accessor :context_setup def loaded? return true if @already_loaded && @already_context super synchronize { non_thread_safe_context_setting unless @already_context } true end def non_thread_safe_context_setting return if @already_context context_setup&.call(loaded_by_identity.values.flatten) @already_context = true end end end end ================================================ FILE: lib/n1_loader/goldiloader/preloader_patch.rb ================================================ # frozen_string_literal: true module N1Loader module Goldiloader # A patch to {N1Loader::Preloader} to setup lazy context lazily. module PreloaderPatch def initialize(elements, context_setup = nil) super(elements) @context_setup = context_setup end def preload(*keys) super.each do |loader_collection| loader_collection.context_setup = context_setup end end private attr_reader :context_setup end end end ================================================ FILE: lib/n1_loader/goldiloader.rb ================================================ # frozen_string_literal: true # Load core library require_relative "active_record" # Load integration dependency require "goldiloader" # Library integration require_relative "goldiloader/loadable" require_relative "goldiloader/context_adapter" require_relative "goldiloader/loader_collection_patch" require_relative "goldiloader/preloader_patch" require_relative "goldiloader/loader_patch" require_relative "goldiloader/loader" require_relative "goldiloader/context" N1Loader::Loadable.prepend(N1Loader::Goldiloader::Loadable) N1Loader::Preloader.prepend(N1Loader::Goldiloader::PreloaderPatch) N1Loader::Loader.prepend(N1Loader::Goldiloader::LoaderPatch) N1Loader::LoaderCollection.prepend(N1Loader::Goldiloader::LoaderCollectionPatch) ================================================ FILE: lib/n1_loader/version.rb ================================================ # frozen_string_literal: true module N1Loader VERSION = "3.0.0" end ================================================ FILE: lib/n1_loader.rb ================================================ # frozen_string_literal: true require_relative "n1_loader/version" require_relative "n1_loader/core/loader_builder" require_relative "n1_loader/core/loader" require_relative "n1_loader/core/loader_collection" require_relative "n1_loader/core/loadable" require_relative "n1_loader/core/preloader" module N1Loader # :nodoc: class Error < StandardError; end class NotImplemented < Error; end class NotLoaded < Error; end class NotFilled < Error; end class MissingArgument < Error; end class InvalidArgument < Error; end class InvalidBinding < Error; end end ================================================ FILE: n1_loader.gemspec ================================================ # frozen_string_literal: true require_relative "lib/n1_loader/version" Gem::Specification.new do |spec| spec.name = "n1_loader" spec.version = N1Loader::VERSION spec.authors = ["Evgeniy Demin"] spec.email = ["lawliet.djez@gmail.com"] spec.summary = "Loader to solve N+1 issue for good." spec.homepage = "https://github.com/djezzzl/n1_loader" spec.license = "MIT" spec.required_ruby_version = ">= 2.5.0" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://github.com/djezzzl/n1_loader" spec.metadata["changelog_uri"] = "https://github.com/djezzzl/n1_loader/master/CHANGELOG.md" spec.metadata["funding_uri"] = "https://opencollective.com/n1_loader#support" spec.files = Dir["lib/**/*"] spec.require_paths = ["lib"] spec.add_runtime_dependency "mutex_m" spec.add_development_dependency "activerecord", ">= 5" spec.add_development_dependency "ar_lazy_preload", ">= 0.6" spec.add_development_dependency "db-query-matchers", "~> 0.11" spec.add_development_dependency "goldiloader", ">= 3" spec.add_development_dependency "graphql", "~> 2.0" spec.add_development_dependency "rails", ">= 5" spec.add_development_dependency "rspec", "~> 3.0" spec.add_development_dependency "rspec_junit_formatter", "~> 0.4" spec.add_development_dependency "rubocop", "~> 1.7" spec.add_development_dependency "sqlite3", ">= 1.3" end ================================================ FILE: spec/activerecord_spec.rb ================================================ # frozen_string_literal: true RSpec.describe "N1Loader ActiveRecord integration" do require_relative "../lib/n1_loader/active_record" before do ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") ActiveRecord::Base.connection.tables.each do |table| ActiveRecord::Base.connection.drop_table(table, force: :cascade) end ActiveRecord::Schema.verbose = false ActiveRecord::Schema.define(version: 1) do create_table(:entities) create_table(:companies) do |t| t.belongs_to :entity end create_table(:employees) do |t| t.belongs_to :company end create_table(:assignments) do |t| t.belongs_to :employee end end stub_const("Entity", Class.new(ActiveRecord::Base) do self.table_name = :entities has_one :company, class_name: "Company" class << self def name "Entity" end def perform! @count = count + 1 end def count @count || 0 end end n1_optimized :data do def perform(elements) Entity.perform! elements.each { |element| fulfill(element, [element]) } end end n1_optimized :with_arguments do argument :something def perform(elements) Entity.perform! elements.each { |element| fulfill(element, [element, something]) } end end end) stub_const("Company", Class.new(ActiveRecord::Base) do self.table_name = :companies belongs_to :entity, class_name: "Entity" has_many :employees, class_name: "Employee" class << self def name "Company" end def perform! @count = count + 1 end def count @count || 0 end end n1_optimized :data do def perform(elements) Company.perform! hash = Entity.where(id: elements.map(&:entity_id)).index_by(&:id) elements.each { |element| fulfill(element, hash[element.entity_id]) } end end n1_optimized :with_arguments do argument :something def perform(elements) Company.perform! hash = Entity.where(id: elements.map(&:entity_id)).index_by(&:id) elements.each { |element| fulfill(element, [hash[element.entity_id], something]) } end end n1_optimized :with_question_mark? do argument :something def perform(elements) Entity.perform! elements.each { |element| fulfill(element, [element, something]) } end end n1_optimized :employee_data do def perform(elements) Company.perform! employees_hash = Employee.where(company_id: elements.map(&:id)).group_by(&:company_id) elements.each { |element| fulfill(element, employees_hash[element.id] || []) } end end end) stub_const("Employee", Class.new(ActiveRecord::Base) do self.table_name = :employees belongs_to :company, class_name: "Company" has_many :assignments, class_name: "Assignment" class << self def name "Employee" end def perform! @count = count + 1 end def count @count || 0 end end end) stub_const("Assignment", Class.new(ActiveRecord::Base) do self.table_name = :assignments belongs_to :employee, class_name: "Employee" class << self def name "Assignment" end def perform! @count = count + 1 end def count @count || 0 end end end) end let(:loader) do Class.new(N1Loader::Loader) do def perform(elements) elements.each { |element| fulfill(element, [element]) } end end end let(:object) { Entity.create! } it "works" do expect { object.data }.to change(Entity, :count).by(1) expect { object.data }.not_to change(Entity, :count) expect(object.data).to eq([object]) end describe "loaded comparison" do it "compares by value" do instance = loader.new([object]) expect(instance.for(object)).to eq([object]) expect(instance.for(Entity.find(object.id))).to eq([object]) expect { instance.for(Entity.create!) }.to raise_error(N1Loader::NotLoaded) end end describe "question mark support" do it "works" do expect do Company.includes(:with_question_mark?).each do |company| expect(company.with_question_mark?(something: "something")).to eq([company, "something"]) end end.not_to raise_error end end context "with preloader" do let(:objects) { [Entity.create!, Entity.create!] } it "works" do expect { N1Loader::Preloader.new(objects).preload(:data) }.not_to change(Entity, :count) expect do objects.each do |object| expect(object.data).to eq([object]) end end.to change(Entity, :count).by(1) end end context "with includes" do let(:objects) { Entity.includes(:data) } before do Entity.create! Entity.create! end it "works" do expect do objects.each do |object| expect(object.data).to eq([object]) end end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(count: 1) .and change(Entity, :count).by(1) end context "when preloading AR" do let(:objects) { Entity.includes(:company) } it "doesn't set context further for N1Loader" do expect do objects.each do |object| expect(object.company.data).to eq(object) end .to make_database_queries(matching: /companies/, count: 1) .and make_database_queries(matching: /entities/, count: 2) .and make_database_queries(count: 3) end end end context "with arguments" do before { skip "unsupported by ArLazyPreload" if ar_lazy_preload_defined? } let(:objects) { Entity.includes(:with_arguments) } it "works" do expect do expect do objects.each do |object| object.with_arguments(something: "something") end end.to change(Entity, :count).by(1) expect do objects.each do |object| object.with_arguments(something: "something") end end.not_to change(Entity, :count) expect do objects.each do |object| object.with_arguments(something: "anything") end end.to change(Entity, :count).by(1) objects.each do |object| expect(object.with_arguments(something: "something")).to eq([object, "something"]) end end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(count: 1) end end end context "with nested includes" do let(:objects) { Entity.includes(company: %i[entity data] + [{ employee_data: :assignments }]) } before do Company.create!(entity: Entity.create!, employees: [Employee.create!(assignments: [Assignment.create!])]) Company.create!(entity: Entity.create!, employees: [Employee.create!(assignments: [Assignment.create!])]) end it "works" do expect do objects.each do |object| expect(object.company.data).to eq(object) end end .to make_database_queries(matching: /entities/, count: 2) .and make_database_queries(matching: /companies/, count: 1) .and make_database_queries(matching: /employees/, count: 1) .and make_database_queries(matching: /assignments/, count: 1) .and make_database_queries(count: 5) .and change(Company, :count).by(2) objects.each do |object| expect(object.company.employee_data).to eq(object.company.employees) expect(object.company.employee_data.map(&:assignments)).to eq(object.company.employees.map(&:assignments)) end end context "with arguments" do let(:objects) { Entity.includes(company: :with_arguments) } before { skip "unsupported by ActiveRecord 6" if ar_version == 6 } it "works" do expect do objects.each do |object| expect(object.company.with_arguments(something: "something")).to eq([object, "something"]) end end .to make_database_queries(matching: /entities/, count: 2) .and make_database_queries(matching: /companies/, count: 1) .and make_database_queries(count: 3) .and change(Company, :count).by(1) end end end context "with deep includes" do let(:objects) { Company.includes(data: :company) } before do Company.create!(entity: Entity.create!) Company.create!(entity: Entity.create!) end it "works" do expect do objects.each do |object| expect(object.data.company.id).to eq(object.id) end end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(matching: /companies/, count: 2) .and make_database_queries(count: 3) .and change(Company, :count).by(1) end context "with arguments" do let(:objects) { Company.includes(with_arguments: :company) } it "doesn't work" do expect do objects.each do |object| expect(object.with_arguments(something: "something").first.company.id).to eq(object.id) end end.to raise_error(N1Loader::ActiveRecord::InvalidPreloading) end end end end ================================================ FILE: spec/ar_lazy_preload_spec.rb ================================================ # frozen_string_literal: true RSpec.describe "N1Loader AR Lazy Preload integration" do require_relative "../lib/n1_loader/ar_lazy_preload" ActiveSupport.on_load(:active_record) do ActiveRecord::Base.include(ArLazyPreload::Base) ActiveRecord::Relation.prepend(ArLazyPreload::Relation) ActiveRecord::AssociationRelation.prepend(ArLazyPreload::AssociationRelation) ActiveRecord::Relation::Merger.prepend(ArLazyPreload::Merger) [ ActiveRecord::Associations::CollectionAssociation, ActiveRecord::Associations::Association ].each { |klass| klass.prepend(ArLazyPreload::Association) } ActiveRecord::Associations::CollectionAssociation.prepend(ArLazyPreload::CollectionAssociation) ActiveRecord::Associations::CollectionProxy.prepend(ArLazyPreload::CollectionProxy) ArLazyPreload::Preloader.patch_for_rails_7! if ActiveRecord::VERSION::MAJOR >= 7 end before do ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") ActiveRecord::Base.connection.tables.each do |table| ActiveRecord::Base.connection.drop_table(table, force: :cascade) end ActiveRecord::Schema.verbose = false ActiveRecord::Schema.define(version: 1) do create_table(:entities) create_table(:companies) do |t| t.belongs_to :entity end end stub_const("Entity", Class.new(ActiveRecord::Base) do self.table_name = :entities has_one :company, class_name: "Company" class << self def name "Entity" end def perform! @count = count + 1 end def count @count || 0 end end n1_optimized :data do def perform(elements) elements.first.class.perform! elements.each { |element| fulfill(element, [element]) } end end n1_optimized :with_arguments do argument :something def perform(elements) Entity.perform! elements.each { |element| fulfill(element, [element, something]) } end end end) stub_const("Company", Class.new(ActiveRecord::Base) do self.table_name = :companies belongs_to :entity, class_name: "Entity" class << self def name "Company" end def perform! @count = count + 1 end def count @count || 0 end end n1_optimized :data do def perform(elements) elements.first.class.perform! hash = Entity.where(id: elements.map(&:entity_id)).index_by(&:id) elements.each { |element| fulfill(element, hash[element.entity_id]) } end end n1_optimized :with_arguments do argument :something def perform(elements) Company.perform! hash = Entity.where(id: elements.map(&:entity_id)).index_by(&:id) elements.each { |element| fulfill(element, [hash[element.entity_id], something]) } end end n1_optimized :with_question_mark? do argument :something def perform(elements) Entity.perform! elements.each { |element| fulfill(element, [element, something]) } end end end) end before do Company.create!(entity: Entity.create!) Company.create!(entity: Entity.create!) end describe "question mark support" do it "works" do expect do Company.preload_associations_lazily.each do |company| expect(company.with_question_mark?(something: "something")).to eq([company, "something"]) end end.not_to raise_error end end it "works" do expect do Company.preload_associations_lazily.all.map(&:data) end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(matching: /companies/, count: 1) .and make_database_queries(count: 2) .and change(Company, :count).by(1) expect do Entity.preload_associations_lazily.all.map(&:company).map(&:data) end .to make_database_queries(matching: /entities/, count: 2) .and make_database_queries(matching: /companies/, count: 1) .and make_database_queries(count: 3) .and change(Company, :count).by(1) expect do Company.preload_associations_lazily.all.map(&:data).map(&:company) end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(matching: /companies/, count: 2) .and make_database_queries(count: 3) .and change(Company, :count).by(1) expect do Company.lazy_preload(data: :company).map(&:data).map(&:company) end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(matching: /companies/, count: 2) .and make_database_queries(count: 3) .and change(Company, :count).by(1) end context "with arguments" do it "works" do expect do Company.preload_associations_lazily.all.each { |company| company.with_arguments(something: "something") } end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(matching: /companies/, count: 1) .and make_database_queries(count: 2) .and change(Company, :count).by(1) expect do Entity.preload_associations_lazily.all.each { |entity| entity.company.with_arguments(something: "something") } end .to make_database_queries(matching: /entities/, count: 2) .and make_database_queries(matching: /companies/, count: 1) .and make_database_queries(count: 3) expect do Company.preload_associations_lazily.each do |company| company.with_arguments(something: "something").first.company end end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(matching: /companies/, count: 2) .and make_database_queries(count: 3) expect do Company.lazy_preload(with_arguments: :company).each do |company| company.with_arguments(something: "something").first.company end end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(matching: /companies/, count: 2) .and make_database_queries(count: 3) end end describe "isolated loaders" do let(:loader) do Class.new(N1Loader::Loader) do argument :something def perform(_companies) Company.perform! hash = Entity.where(id: elements.map(&:entity_id)).index_by(&:id) elements.each { |element| fulfill(element, [hash[element.entity_id], something]) } end end end it "works with ArLazyPreload context" do companies = Company.preload_associations_lazily.order(:id).to_a entity1 = Entity.first entity2 = Entity.second loaded1 = loader.for(companies.first, something: "tmp") loaded2 = loader.for(companies.second, something: "tmp") expect(loaded1).to eq([entity1, "tmp"]) expect(loaded2).to eq([entity2, "tmp"]) expect(loaded1[0].lazy_preload_context) .to be_present .and eq(loaded2[0].lazy_preload_context) expect do companies.map do |company| loader.for(company, something: "something") end end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(count: 1) .and change(Company, :count).by(1) expect do companies.each do |company| loader.for(company, something: "anything") end end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(count: 1) .and change(Company, :count).by(1) end context "when object does not have ArLazyPreload context" do it "raises an error" do expect do loader.for("string", something: "something") end.to raise_error(N1Loader::Loader::UnsupportedArLazyPreload) expect do loaded = loader.for(Company.first, something: "something") expect(loaded[0].lazy_preload_context).to be_present end.not_to raise_error end end end end ================================================ FILE: spec/goldiloader_spec.rb ================================================ # frozen_string_literal: true RSpec.describe "N1Loader Goldiloader integration" do require_relative "../lib/n1_loader/goldiloader" before do ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") ActiveRecord::Base.connection.tables.each do |table| ActiveRecord::Base.connection.drop_table(table, force: :cascade) end ActiveRecord::Schema.verbose = false ActiveRecord::Schema.define(version: 1) do create_table(:entities) create_table(:companies) do |t| t.belongs_to :entity end end stub_const("Entity", Class.new(ActiveRecord::Base) do self.table_name = :entities has_one :company, class_name: "Company" class << self def name "Entity" end def perform! @count = count + 1 end def count @count || 0 end end n1_optimized :data do def perform(elements) elements.first.class.perform! elements.each { |element| fulfill(element, [element]) } end end n1_optimized :with_arguments do argument :something def perform(elements) Entity.perform! elements.each { |element| fulfill(element, [element, something]) } end end end) stub_const("Company", Class.new(ActiveRecord::Base) do self.table_name = :companies belongs_to :entity, class_name: "Entity" class << self def name "Company" end def perform! @count = count + 1 end def count @count || 0 end end n1_optimized :data do def perform(elements) elements.first.class.perform! hash = Entity.where(id: elements.map(&:entity_id)).index_by(&:id) elements.each { |element| fulfill(element, hash[element.entity_id]) } end end n1_optimized :with_arguments do argument :something def perform(elements) Company.perform! hash = Entity.where(id: elements.map(&:entity_id)).index_by(&:id) elements.each { |element| fulfill(element, [hash[element.entity_id], something]) } end end n1_optimized :with_question_mark? do argument :something def perform(elements) Entity.perform! elements.each { |element| fulfill(element, [element, something]) } end end end) end before do Company.create!(entity: Entity.create!) Company.create!(entity: Entity.create!) end describe "question mark support" do it "works" do expect do Company.all.each do |company| expect(company.with_question_mark?(something: "something")).to eq([company, "something"]) end end.not_to raise_error end end it "works" do expect do Company.all.map(&:data) end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(matching: /companies/, count: 1) .and make_database_queries(count: 2) .and change(Company, :count).by(1) expect do Entity.all.map(&:company).map(&:data) end .to make_database_queries(matching: /entities/, count: 2) .and make_database_queries(matching: /companies/, count: 1) .and make_database_queries(count: 3) .and change(Company, :count).by(1) expect do Company.all.map(&:data).map(&:company) end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(matching: /companies/, count: 2) .and make_database_queries(count: 3) .and change(Company, :count).by(1) end context "with arguments" do it "works" do expect do Company.all.each { |company| company.with_arguments(something: "something") } end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(matching: /companies/, count: 1) .and make_database_queries(count: 2) .and change(Company, :count).by(1) expect do Entity.all.each { |entity| entity.company.with_arguments(something: "something") } end .to make_database_queries(matching: /entities/, count: 2) .and make_database_queries(matching: /companies/, count: 1) .and make_database_queries(count: 3) expect do Company.all.each do |company| company.with_arguments(something: "something").first.company end end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(matching: /companies/, count: 2) .and make_database_queries(count: 3) end end describe "isolated loaders" do let(:loader) do Class.new(N1Loader::Loader) do argument :something def perform(_companies) Company.perform! hash = Entity.where(id: elements.map(&:entity_id)).index_by(&:id) elements.each { |element| fulfill(element, [hash[element.entity_id], something]) } end end end it "works with Goldiloader context" do companies = Company.order(:id).to_a entity1 = Entity.first entity2 = Entity.second loaded1 = loader.for(companies.first, something: "tmp") loaded2 = loader.for(companies.second, something: "tmp") expect(loaded1).to eq([entity1, "tmp"]) expect(loaded2).to eq([entity2, "tmp"]) expect(loaded1[0].auto_include_context) .to be_present .and eq(loaded2[0].auto_include_context) expect do companies.map do |company| loader.for(company, something: "something") end end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(count: 1) .and change(Company, :count).by(1) expect do companies.each do |company| loader.for(company, something: "anything") end end .to make_database_queries(matching: /entities/, count: 1) .and make_database_queries(count: 1) .and change(Company, :count).by(1) end context "when object does not have Goldiloader context" do it "raises an error" do expect do loader.for("string", something: "something") end.to raise_error(N1Loader::Loader::UnsupportedGoldiloader) expect do loaded = loader.for(Company.first, something: "something") expect(loaded[0].auto_include_context).to be_present end.not_to raise_error end end end end ================================================ FILE: spec/n1_loader_spec.rb ================================================ # frozen_string_literal: true RSpec.describe N1Loader do let(:loader) do Class.new(N1Loader::Loader) do def perform(elements) elements.each { |element| fulfill(element, [element]) } end end end let(:klass) do custom_loader = loader Class.new do include N1Loader::Loadable class << self def perform! @count = count + 1 end def count @count || 0 end end n1_optimized :inline do |elements| elements.first.class.perform! elements.each { |element| fulfill(element, [element]) } end n1_optimized :sleepy do |elements| sleep(0.5) elements.first.class.perform! elements.each { |element| fulfill(element, [element]) } end n1_optimized :custom, custom_loader n1_optimized :single_optimized do def single(element) [element] end def perform(_elements) raise "unknown" end end n1_optimized :missing_fulfill do def perform(elements) elements.group_by(&:itself) end end n1_optimized :with_arguments do argument :something argument :anything def perform(elements) elements.first.class.perform! elements.each do |element| fulfill(element, [element, something, anything]) end end end n1_optimized :with_optional_argument do argument :something, optional: true argument :anything def perform(elements) elements.first.class.perform! elements.each do |element| fulfill(element, [element, something, anything]) end end end n1_optimized :with_default_argument do argument :something, default: -> { [] } argument :anything def perform(elements) elements.first.class.perform! elements.each do |element| fulfill(element, [element, something, anything]) end end end n1_optimized :with_custom_arguments_key do argument :something argument :anything cache_key { something + anything } def perform(elements) elements.first.class.perform! elements.each do |element| fulfill(element, [element, something, anything]) end end end n1_optimized :with_question_mark? do argument :something def perform(elements) elements.first.class.perform! elements.each do |element| fulfill(element, [element, something]) end end end n1_optimized :new_objects do |elements| elements.first.class.perform! objects = elements.map { elements.first.class.new } elements.each_with_index do |element, index| fulfill(element, objects[index]) end end end end let(:child_klass) do Class.new(klass) do n1_optimized :child_something do |elements| elements.first.class.perform! elements.each do |element| fulfill(element, [element]) end end end end let(:object) { klass.new } let(:objects) { [klass.new, klass.new] } it "works with unsupported objects" do expect do N1Loader::Preloader.new([object, 123]).preload(:with_arguments) end.not_to raise_error(NoMethodError) end describe "thread-safety" do it "is thread-safe" do N1Loader::Preloader.new(objects).preload(:sleepy) threads = [] 10.times do threads << Thread.new do objects.each do |obj| expect(obj.sleepy).to eq([obj]) end end end threads.each(&:join) expect(klass.count).to eq(1) end end describe "error handling" do it "raises the same error on the subsequent calls" do faulty_klass = Class.new do include N1Loader::Loadable n1_optimized :faulty do |_| raise StandardError, "Something went wrong" end end faulty_object = faulty_klass.new expect { faulty_object.faulty }.to raise_error(StandardError, "Something went wrong") expect { faulty_object.faulty }.to raise_error(StandardError, "Something went wrong") end end describe "loaded comparison" do it "compares by identity first" do instance = loader.new(objects) expect(objects.first).to equal(objects.first) expect(instance.for(objects.first)).to eq([objects.first]) expect { instance.for(object) }.to raise_error(N1Loader::NotLoaded) end it "falls back to equality comparison when no identity match" do equal_klass = Struct.new(:id) original = equal_klass.new(1) equal_copy = equal_klass.new(1) custom_loader = Class.new(N1Loader::Loader) do def perform(elements) elements.each { |element| fulfill(element, [element]) } end end instance = custom_loader.new([original]) expect(original).not_to equal(equal_copy) expect(instance.for(original)).to eq([original]) expect(instance.for(equal_copy)).to eq([original]) end end context "when fulfill was not used" do it "throws error" do expect { object.missing_fulfill } .to raise_error(N1Loader::NotFilled, "Nothing was preloaded, perhaps you forgot to use fulfill method") end end describe "question mark support" do it "works" do expect { object.with_question_mark?(something: "something") }.not_to raise_error expect(object.with_question_mark?(something: "something")).to eq([object, "something"]) end end describe "clear cache" do it "works" do expect { object.inline }.to change(klass, :count).by(1) expect { object.inline }.not_to change(klass, :count) object.n1_clear_cache expect { object.inline }.to change(klass, :count).by(1) expect { object.inline }.not_to change(klass, :count) end context "with parent loader" do let(:object) { child_klass.new } it "works" do expect { object.inline }.to change(child_klass, :count).by(1) expect { object.child_something }.to change(child_klass, :count).by(1) expect { object.inline }.not_to change(child_klass, :count) expect { object.child_something }.not_to change(child_klass, :count) object.n1_clear_cache expect { object.inline }.to change(child_klass, :count).by(1) expect { object.child_something }.to change(child_klass, :count).by(1) expect { object.inline }.not_to change(child_klass, :count) expect { object.child_something }.not_to change(child_klass, :count) end end context "with binding" do it "clears binding so objects load independently after cache clear" do objects.each { |obj| obj.n1_bind_to(objects) } # Binding ensures the whole collection loads in one batch expect { objects.first.inline }.to change(klass, :count).by(1) expect { objects.last.inline }.not_to change(klass, :count) objects.each(&:n1_clear_cache) # After clearing cache and binding, each object loads independently expect { objects.first.inline }.to change(klass, :count).by(1) expect { objects.last.inline }.to change(klass, :count).by(1) end end end describe "arguments support" do it "has to receive all arguments" do expect { object.with_arguments }.to raise_error(N1Loader::MissingArgument) expect { object.with_arguments(something: "something") }.to raise_error(N1Loader::MissingArgument) expect { object.with_arguments("something") }.to raise_error(ArgumentError) expect(object.with_arguments(something: "something", anything: "anything")).to eq([object, "something", "anything"]) end it "supports optional arguments" do expect { object.with_optional_argument } .to raise_error(N1Loader::MissingArgument, "Loader requires [:anything] arguments but they are missing") expect(object.with_optional_argument(anything: 2)).to eq([object, nil, 2]) expect(object.with_optional_argument(something: 1, anything: 2)).to eq([object, 1, 2]) expect { object.with_optional_argument(tmp: 1, anything: 2) } .to raise_error(N1Loader::InvalidArgument, "Loader doesn't define tmp argument") end it "supports default arguments" do expect { object.with_default_argument } .to raise_error(N1Loader::MissingArgument, "Loader requires [:anything] arguments but they are missing") expect(object.with_default_argument(anything: 2)).to eq([object, [], 2]) expect(object.with_default_argument(something: 1, anything: 2)).to eq([object, 1, 2]) expect { object.with_default_argument(tmp: 1, anything: 2) } .to raise_error(N1Loader::InvalidArgument, "Loader doesn't define tmp argument") end it "can have custom arguments key" do # The following two has the same result for custom key, so we only do one perform expect { object.with_custom_arguments_key(something: 1, anything: 2) }.to change(klass, :count).by(1) expect { object.with_custom_arguments_key(something: 2, anything: 1) }.not_to change(klass, :count) expect { object.with_custom_arguments_key(something: 2, anything: 3) }.to change(klass, :count).by(1) end it "supports named arguments" do expect do object.with_custom_arguments_key end.to raise_error(N1Loader::MissingArgument, "Loader requires [:something, :anything] arguments but they are missing") expect do object.with_custom_arguments_key(something: "something") end.to raise_error(N1Loader::MissingArgument, "Loader requires [:anything] arguments but they are missing") expect(object.with_custom_arguments_key(something: "something", anything: "anything")).to eq([ object, "something", "anything" ]) end it "supports falsey argument values" do expect(object.with_default_argument(anything: 2)).to eq([object, [], 2]) # default value expect(object.with_default_argument(something: false, anything: 2)).to eq([object, false, 2]) # false expect(object.with_default_argument(something: nil, anything: 2)).to eq([object, nil, 2]) # nil end it "works with preloading" do N1Loader::Preloader.new(objects).preload(:with_arguments) expect do objects.each do |object| expect(object.with_arguments(something: "something", anything: "anything")).to eq([object, "something", "anything"]) end end.to change(klass, :count).by(1) end it "caches based on arguments" do N1Loader::Preloader.new(objects).preload(:with_arguments, :with_default_argument) expect do objects.each { |object| object.with_arguments(something: "something", anything: "anything") } end.to change(klass, :count).by(1) expect do objects.each { |object| object.with_arguments(something: "something2", anything: "anything") } end.to change(klass, :count).by(1) expect do objects.each { |object| object.with_arguments(something: "something", anything: "anything2") } end.to change(klass, :count).by(1) expect do objects.each { |object| object.with_arguments(something: "something", anything: "anything") } end.not_to change(klass, :count) expect do objects.each { |object| object.with_default_argument(something: false, anything: nil) } end.to change(klass, :count).by(1) expect do objects.each { |object| object.with_default_argument(something: false, anything: nil) } end.not_to change(klass, :count) end it "supports reloading" do expect do object.with_arguments(something: "something", anything: "anything") end.to change(klass, :count).by(1) expect do object.with_arguments(something: "something", anything: "anything", reload: true) end.to change(klass, :count).by(1) expect do object.with_arguments(something: "something", anything: "anything") end.not_to change(klass, :count) end end describe "optimization for single object" do it "uses optimization" do expect(object.single_optimized).to eq([object]) N1Loader::Preloader.new(objects).preload(:single_optimized) expect { objects.map(&:single_optimized) }.to raise_error(StandardError, "unknown") end end describe "isolated loaders" do it "does not need injection" do instance = loader.new(objects) objects.each do |object| expect(instance.for(object)).to eq([object]) end end it "checks that element was provided" do instance = loader.new(objects) objects.each do |object| expect(instance.for(object)).to eq([object]) end expect do instance.for(object) end.to raise_error(N1Loader::NotLoaded, "The data was not preloaded for the given element") end end describe "reloading" do context "with preloading" do it "reloads cached data" do N1Loader::Preloader.new(objects).preload(:inline) expect { objects.map(&:inline) }.to change(klass, :count).by(1) N1Loader::Preloader.new(objects).preload(:inline) expect { objects.map(&:inline) }.to change(klass, :count).by(1) expect { objects.map(&:inline) }.not_to change(klass, :count) end end context "without preloading" do it "reloads cached data" do expect { object.inline }.to change(klass, :count).by(1) expect { object.inline(reload: true) }.to change(klass, :count).by(1) expect { object.inline(reload: false) }.not_to change(klass, :count) expect { object.inline }.not_to change(klass, :count) end end end context "with custom loader" do it "works" do expect(object.custom).to eq([object]) end end context "without preloading" do it "returns right data" do expect(object.inline).to eq([object]) end it "caches data" do expect { object.inline }.to change(klass, :count).by(1) expect { object.inline }.not_to change(klass, :count) end end context "with preloading" do it "returns right data" do N1Loader::Preloader.new(objects).preload(:inline) expect(objects.first.inline).to eq([objects.first]) expect(objects.last.inline).to eq([objects.last]) end it "lazy loads data" do expect { N1Loader::Preloader.new(objects).preload(:inline) }.not_to change(klass, :count) expect { objects.map(&:inline) }.to change(klass, :count).by(1) end it "uses preloaded data" do N1Loader::Preloader.new(objects).preload(:inline) expect { objects.map(&:inline) }.to change(klass, :count).by(1) expect { objects.map(&:inline) }.not_to change(klass, :count) end end describe "n1_bind_to" do it "raises error if invalid collection was passed" do expect do objects.each { |obj| obj.n1_bind_to([]) } end.to raise_error N1Loader::InvalidBinding expect do objects.first.n1_bind_to([objects.last]) end.to raise_error N1Loader::InvalidBinding end it "loads all bound objects in a single batch" do objects.each { |obj| obj.n1_bind_to(objects) } expect { objects.map(&:inline) }.to change(klass, :count).by(1) end it "caches the result after the first load" do objects.each { |obj| obj.n1_bind_to(objects) } expect { objects.map(&:inline) }.to change(klass, :count).by(1) expect { objects.map(&:inline) }.not_to change(klass, :count) end it "lazily loads when the first object is accessed" do objects.each { |obj| obj.n1_bind_to(objects) } expect { objects.first.inline }.to change(klass, :count).by(1) expect { objects.last.inline }.not_to change(klass, :count) end it "propagates context to loaded objects" do objects.each { |obj| obj.n1_bind_to(objects) } expect do objects.each(&:new_objects) end.to change(klass, :count).by(1) expect do objects.map(&:new_objects).map(&:inline) end.to change(klass, :count).by(1) end end describe "with preloader" do it "doesn't propagate context to loaded objects" do N1Loader::Preloader.new(objects).preload(:new_objects) expect do objects.each(&:new_objects) end.to change(klass, :count).by(1) expect do objects.map(&:new_objects).map(&:inline) end.to change(klass, :count).by(2) end end end ================================================ FILE: spec/spec_helper.rb ================================================ # frozen_string_literal: true require "n1_loader" require "db-query-matchers" DBQueryMatchers.configure do |config| config.schemaless = true end RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" # Disable RSpec exposing methods globally on `Module` and `main` config.disable_monkey_patching! config.expect_with :rspec do |c| c.syntax = :expect end def ar_lazy_preload_defined? defined?(ArLazyPreload) end def ar_version ActiveRecord::VERSION::MAJOR end end