n1_loader 1.5.0 → 1.6.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bf26363e90cf5f0b6759776698445183f4abe8442bf366e7109e1cada18b1eaa
4
- data.tar.gz: e082a72dc1f910262fbc73040b484ae28e437beee866ca42277a7556d989072a
3
+ metadata.gz: 3d914dd09225515a579945ab616916bf1ad446101c5051c4cf1b72c46d38c107
4
+ data.tar.gz: a304c32e35294c5646fafe5c65a5de6c637e80e080d80ac16f957d77a3ebe4c6
5
5
  SHA512:
6
- metadata.gz: 52993103afc0075d9d71088e6d7e60925ea1740176ee31829acb5d42009a38955b25d8e9124a17fff981b4735f443c04d3532cbea3186850bffc2908f0e40522
7
- data.tar.gz: fdcc21a626ff199ccf4a9af7bd860516745693614404c44159414b27601750ea476de8eae593f7bade3782425b638f784db096e6dcdaafa58c71382f3a08b979
6
+ metadata.gz: 738d8ca021bca48a41486689c74506af32dc2bca8a542398272edaa72aa57dd49247249d04afe8d9b834cc28cd9b725f0a9b1d8d7f5dd80176cfd3c9ca711f08
7
+ data.tar.gz: c73ddc81593e0cbd0450457143726b9d57fbce0a27c09ede70ea3f269816b7ae7374e5dc1c06dda4e8b0ee8c21a0c96248ed4afb9e3d7f26ec5b5ac46e038d16
data/.circleci/config.yml CHANGED
@@ -100,7 +100,6 @@ workflows:
100
100
  matrix:
101
101
  parameters:
102
102
  ruby-version: [
103
- "2.5",
104
103
  "2.7",
105
104
  "latest"
106
105
  ]
@@ -114,19 +113,6 @@ workflows:
114
113
  "ar_lazy_preload_master"
115
114
  ]
116
115
  exclude:
117
- # Ruby 2.5 and AR Lazy Preload 1+
118
- - ruby-version: "2.5"
119
- ar_lazy_preload-gemfile: "ar_lazy_preload_master"
120
- activerecord-gemfile: "ar_5_latest"
121
-
122
- - ruby-version: "2.5"
123
- ar_lazy_preload-gemfile: "ar_lazy_preload_master"
124
- activerecord-gemfile: "ar_6_latest"
125
-
126
- - ruby-version: "2.5"
127
- ar_lazy_preload-gemfile: "ar_lazy_preload_master"
128
- activerecord-gemfile: "ar_7_latest"
129
-
130
116
  # AR 5 and ruby 3+
131
117
  - ruby-version: "latest"
132
118
  activerecord-gemfile: "ar_5_latest"
@@ -0,0 +1,23 @@
1
+ name: Rubocop
2
+
3
+ on:
4
+ - pull_request
5
+
6
+ jobs:
7
+ tests:
8
+ runs-on: ubuntu-latest
9
+
10
+ steps:
11
+ - uses: actions/checkout@v3
12
+
13
+ - name: Set up Ruby
14
+ uses: ruby/setup-ruby@v1
15
+ with:
16
+ ruby-version: 2.7
17
+ bundler-cache: true
18
+
19
+ - name: Install dependencies
20
+ run: bundle install
21
+
22
+ - name: Run Rubocop
23
+ run: bundle exec rubocop
@@ -0,0 +1,62 @@
1
+ name: RSpec tests
2
+
3
+ on:
4
+ - pull_request
5
+
6
+ jobs:
7
+ tests:
8
+ runs-on: ubuntu-latest
9
+
10
+ strategy:
11
+ matrix:
12
+ ruby-version:
13
+ - '2.7'
14
+ - '3.0'
15
+ - 'head'
16
+ activerecord-gemfile:
17
+ - 'ar_5_latest'
18
+ - 'ar_6_latest'
19
+ - 'ar_7_latest'
20
+ ar_lazy_preload-gemfile:
21
+ - 'ar_lazy_preload_0.6.1'
22
+ - 'ar_lazy_preload_master'
23
+ exclude:
24
+ - ruby-version: 'head'
25
+ activerecord-gemfile: 'ar_5_latest'
26
+
27
+ - ruby-version: 'head'
28
+ activerecord-gemfile: 'ar_5_latest'
29
+
30
+ - ruby-version: '3.0'
31
+ activerecord-gemfile: 'ar_5_latest'
32
+
33
+ - ruby-version: '3.0'
34
+ activerecord-gemfile: 'ar_5_latest'
35
+
36
+ - activerecord-gemfile: 'ar_7_latest'
37
+ ar_lazy_preload-gemfile: 'ar_lazy_preload_0.6.1'
38
+
39
+ env:
40
+ ACTIVERECORD_GEMFILE: ${{ matrix.activerecord-gemfile }}
41
+ AR_LAZY_PRELOAD_GEMFILE: ${{ matrix.ar_lazy_preload-gemfile }}
42
+
43
+ steps:
44
+ - uses: actions/checkout@v3
45
+
46
+ - name: Set up Ruby ${{ matrix.ruby-version }}
47
+ uses: ruby/setup-ruby@v1
48
+ with:
49
+ ruby-version: ${{ matrix.ruby-version }}
50
+ bundler-cache: true
51
+
52
+ - name: Install dependencies
53
+ run: bundle install
54
+
55
+ - name: Run Core tests
56
+ run: bundle exec rspec spec/n1_loader_spec.rb
57
+
58
+ - name: Run ActiveRecord tests
59
+ run: bundle exec rspec spec/n1_loader_spec.rb spec/activerecord_spec.rb
60
+
61
+ - name: Run ArLazyPreload tests
62
+ run: bundle exec rspec spec/n1_loader_spec.rb spec/activerecord_spec.rb spec/ar_lazy_preload_spec.rb
data/.rubocop.yml CHANGED
@@ -2,6 +2,7 @@ AllCops:
2
2
  TargetRubyVersion: 2.5
3
3
  Exclude:
4
4
  - examples/**/*
5
+ - vendor/bundle/**/*
5
6
 
6
7
  Style/StringLiterals:
7
8
  Enabled: true
@@ -16,4 +17,4 @@ Layout/LineLength:
16
17
 
17
18
  Metrics/BlockLength:
18
19
  Exclude:
19
- - spec/**/*
20
+ - spec/**/*
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## [1.6.0] - 2022/10/24
2
+
3
+ - Add support of ArLazyPreload context for isolated loaders.
4
+
5
+ ## [1.5.1] - 2022/09/20
6
+
7
+ - 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)!
8
+
1
9
  ## [1.5.0] - 2022/05/01
2
10
 
3
11
  - Add support of Rails 7
data/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![CircleCI][1]][2]
4
4
  [![Gem Version][3]][4]
5
+ [![][9]][10]
5
6
 
6
7
  N1Loader is designed to provide a simple way for avoiding [N+1 issues][7] of any kind.
7
8
  For example, it can help with resolving N+1 for:
@@ -12,6 +13,8 @@ For example, it can help with resolving N+1 for:
12
13
 
13
14
  > [Toptal](https://www.toptal.com#snag-only-shrewd-web-development-experts) is hiring! [Join](https://www.toptal.com#snag-only-shrewd-web-development-experts) as Freelancer or [write me](mailto:lawliet.djez@gmail.com) if you want to join Core team.
14
15
 
16
+ ___Support:___ ActiveRecord 5, 6, and 7.
17
+
15
18
  ## Killer feature for GraphQL API
16
19
 
17
20
  N1Loader in combination with [ArLazyPreload][6] is a killer feature for your GraphQL API.
@@ -24,6 +27,7 @@ gem 'n1_loader', require: 'n1_loader/ar_lazy_preload'
24
27
  ## Enhance [ActiveRecord][5]
25
28
 
26
29
  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!
30
+ Check out the detailed [guide](guides/enhanced-activerecord.md) with examples or its [short version](examples/active_record_integration.rb).
27
31
 
28
32
  ```ruby
29
33
  gem 'n1_loader', require: 'n1_loader/active_record'
@@ -145,6 +149,89 @@ p User.all.includes(:payments_total).map { |user| user.payments_total(from: from
145
149
  - Has [integration](examples/active_record_integration.rb) with [ActiveRecord][5] which makes it brilliant
146
150
  - Has [integration](examples/ar_lazy_integration.rb) with [ArLazyPreload][6] which makes it excellent
147
151
 
152
+ ### Feature killer for [ArLazyPreload][6] integration with isolated loaders
153
+
154
+ In [version 1.6.0](CHANGELOG.md#160---20221019) isolated loaders were integrated with [ArLazyPreload][6] context.
155
+ This means, it isn't required to inject `N1Loader` into your [ActiveRecord][5] models to avoid N+1 issues out of the box.
156
+ It is especially great as many engineers are trying to avoid extra coupling between their models/services when it's possible.
157
+ And this feature was designed exactly for this without losing an out of a box solution for N+1.
158
+
159
+ Without further ado, please have a look at the [example](examples/ar_lazy_integration_with_isolated_loader.rb).
160
+
161
+ _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.
162
+
163
+ ## Funding
164
+
165
+ ### Open Collective Backers
166
+
167
+ 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)]
168
+
169
+ <a href="https://opencollective.com/n1_loader/backer/0/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/0/avatar.svg"></a>
170
+ <a href="https://opencollective.com/n1_loader/backer/1/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/1/avatar.svg"></a>
171
+ <a href="https://opencollective.com/n1_loader/backer/2/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/2/avatar.svg"></a>
172
+ <a href="https://opencollective.com/n1_loader/backer/3/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/3/avatar.svg"></a>
173
+ <a href="https://opencollective.com/n1_loader/backer/4/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/4/avatar.svg"></a>
174
+ <a href="https://opencollective.com/n1_loader/backer/5/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/5/avatar.svg"></a>
175
+ <a href="https://opencollective.com/n1_loader/backer/6/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/6/avatar.svg"></a>
176
+ <a href="https://opencollective.com/n1_loader/backer/7/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/7/avatar.svg"></a>
177
+ <a href="https://opencollective.com/n1_loader/backer/8/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/8/avatar.svg"></a>
178
+ <a href="https://opencollective.com/n1_loader/backer/9/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/9/avatar.svg"></a>
179
+ <a href="https://opencollective.com/n1_loader/backer/10/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/10/avatar.svg"></a>
180
+ <a href="https://opencollective.com/n1_loader/backer/11/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/11/avatar.svg"></a>
181
+ <a href="https://opencollective.com/n1_loader/backer/12/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/12/avatar.svg"></a>
182
+ <a href="https://opencollective.com/n1_loader/backer/13/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/13/avatar.svg"></a>
183
+ <a href="https://opencollective.com/n1_loader/backer/14/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/14/avatar.svg"></a>
184
+ <a href="https://opencollective.com/n1_loader/backer/15/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/15/avatar.svg"></a>
185
+ <a href="https://opencollective.com/n1_loader/backer/16/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/16/avatar.svg"></a>
186
+ <a href="https://opencollective.com/n1_loader/backer/17/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/17/avatar.svg"></a>
187
+ <a href="https://opencollective.com/n1_loader/backer/18/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/18/avatar.svg"></a>
188
+ <a href="https://opencollective.com/n1_loader/backer/19/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/19/avatar.svg"></a>
189
+ <a href="https://opencollective.com/n1_loader/backer/20/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/20/avatar.svg"></a>
190
+ <a href="https://opencollective.com/n1_loader/backer/21/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/21/avatar.svg"></a>
191
+ <a href="https://opencollective.com/n1_loader/backer/22/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/22/avatar.svg"></a>
192
+ <a href="https://opencollective.com/n1_loader/backer/23/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/23/avatar.svg"></a>
193
+ <a href="https://opencollective.com/n1_loader/backer/24/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/24/avatar.svg"></a>
194
+ <a href="https://opencollective.com/n1_loader/backer/25/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/25/avatar.svg"></a>
195
+ <a href="https://opencollective.com/n1_loader/backer/26/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/26/avatar.svg"></a>
196
+ <a href="https://opencollective.com/n1_loader/backer/27/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/27/avatar.svg"></a>
197
+ <a href="https://opencollective.com/n1_loader/backer/28/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/28/avatar.svg"></a>
198
+ <a href="https://opencollective.com/n1_loader/backer/29/website" target="_blank"><img src="https://opencollective.com/n1_loader/backer/29/avatar.svg"></a>
199
+
200
+ ### Open Collective Sponsors
201
+
202
+ 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)]
203
+
204
+ <a href="https://opencollective.com/n1_loader/sponsor/0/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/0/avatar.svg"></a>
205
+ <a href="https://opencollective.com/n1_loader/sponsor/1/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/1/avatar.svg"></a>
206
+ <a href="https://opencollective.com/n1_loader/sponsor/2/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/2/avatar.svg"></a>
207
+ <a href="https://opencollective.com/n1_loader/sponsor/3/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/3/avatar.svg"></a>
208
+ <a href="https://opencollective.com/n1_loader/sponsor/4/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/4/avatar.svg"></a>
209
+ <a href="https://opencollective.com/n1_loader/sponsor/5/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/5/avatar.svg"></a>
210
+ <a href="https://opencollective.com/n1_loader/sponsor/6/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/6/avatar.svg"></a>
211
+ <a href="https://opencollective.com/n1_loader/sponsor/7/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/7/avatar.svg"></a>
212
+ <a href="https://opencollective.com/n1_loader/sponsor/8/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/8/avatar.svg"></a>
213
+ <a href="https://opencollective.com/n1_loader/sponsor/9/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/9/avatar.svg"></a>
214
+ <a href="https://opencollective.com/n1_loader/sponsor/10/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/10/avatar.svg"></a>
215
+ <a href="https://opencollective.com/n1_loader/sponsor/11/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/11/avatar.svg"></a>
216
+ <a href="https://opencollective.com/n1_loader/sponsor/12/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/12/avatar.svg"></a>
217
+ <a href="https://opencollective.com/n1_loader/sponsor/13/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/13/avatar.svg"></a>
218
+ <a href="https://opencollective.com/n1_loader/sponsor/14/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/14/avatar.svg"></a>
219
+ <a href="https://opencollective.com/n1_loader/sponsor/15/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/15/avatar.svg"></a>
220
+ <a href="https://opencollective.com/n1_loader/sponsor/16/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/16/avatar.svg"></a>
221
+ <a href="https://opencollective.com/n1_loader/sponsor/17/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/17/avatar.svg"></a>
222
+ <a href="https://opencollective.com/n1_loader/sponsor/18/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/18/avatar.svg"></a>
223
+ <a href="https://opencollective.com/n1_loader/sponsor/19/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/19/avatar.svg"></a>
224
+ <a href="https://opencollective.com/n1_loader/sponsor/20/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/20/avatar.svg"></a>
225
+ <a href="https://opencollective.com/n1_loader/sponsor/21/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/21/avatar.svg"></a>
226
+ <a href="https://opencollective.com/n1_loader/sponsor/22/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/22/avatar.svg"></a>
227
+ <a href="https://opencollective.com/n1_loader/sponsor/23/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/23/avatar.svg"></a>
228
+ <a href="https://opencollective.com/n1_loader/sponsor/24/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/24/avatar.svg"></a>
229
+ <a href="https://opencollective.com/n1_loader/sponsor/25/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/25/avatar.svg"></a>
230
+ <a href="https://opencollective.com/n1_loader/sponsor/26/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/26/avatar.svg"></a>
231
+ <a href="https://opencollective.com/n1_loader/sponsor/27/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/27/avatar.svg"></a>
232
+ <a href="https://opencollective.com/n1_loader/sponsor/28/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/28/avatar.svg"></a>
233
+ <a href="https://opencollective.com/n1_loader/sponsor/29/website" target="_blank"><img src="https://opencollective.com/n1_loader/sponsor/29/avatar.svg"></a>
234
+
148
235
  ## Contributing
149
236
 
150
237
  Bug reports and pull requests are welcome on GitHub at https://github.com/djezzzl/n1_loader.
@@ -174,3 +261,5 @@ Copyright (c) Evgeniy Demin. See [LICENSE.txt](LICENSE.txt) for further details.
174
261
  [6]: https://github.com/DmitryTsepelev/ar_lazy_preload
175
262
  [7]: https://stackoverflow.com/questions/97197/what-is-the-n1-selects-problem-in-orm-object-relational-mapping
176
263
  [8]: https://github.com/djezzzl/n1_loader
264
+ [9]: https://opencollective.com/n1_loader/tiers/badge.svg
265
+ [10]: https://opencollective.com/n1_loader#support
@@ -29,9 +29,8 @@ fill_database
29
29
  # Has N+1
30
30
  p User.all.map { |user| user.payments.sum(&:amount) }
31
31
 
32
- # Has no N+1 but we load too many data that we don't need
32
+ # Has no N+1 and loads only required data
33
33
  p User.preload_associations_lazily.map(&:payments_total)
34
-
35
- # Has no N+1 and calculation is the most efficient
34
+ # or
36
35
  ArLazyPreload.config.auto_preload = true
37
- User.all.map(&:payments_total)
36
+ User.all.map(&:payments_total)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "n1_loader/ar_lazy_preload"
4
+
5
+ require_relative 'context/setup_ar_lazy'
6
+ require_relative 'context/setup_database'
7
+
8
+ class Loader < N1Loader::Loader
9
+ def perform(users)
10
+ total_per_user = Payment.group(:user_id).where(user: users).sum(:amount).tap { |h| h.default = 0 }
11
+
12
+ users.each do |user|
13
+ total = total_per_user[user.id]
14
+ fulfill(user, total)
15
+ end
16
+ end
17
+ end
18
+
19
+ class User < ActiveRecord::Base
20
+ has_many :payments
21
+ end
22
+
23
+ class Payment < ActiveRecord::Base
24
+ belongs_to :user
25
+
26
+ validates :amount, presence: true
27
+ end
28
+
29
+ fill_database
30
+
31
+ # Has N+1 and loads redundant data
32
+ p User.all.map { |user| user.payments.sum(&:amount) }
33
+
34
+ # Has no N+1 and loads only required data
35
+ p User.preload_associations_lazily.all.map { |user| Loader.for(user) }
36
+
37
+ # or
38
+ ArLazyPreload.config.auto_preload = true
39
+ p User.all.map { |user| Loader.for(user) }
@@ -0,0 +1,266 @@
1
+ # Enhanced ActiveRecord
2
+
3
+ - Do you like `ActiveRecord` preloading?
4
+ - How many times have you resolved your N+1 issues with `includes` or `preload`?
5
+ - Do you know that preloading has limitations?
6
+
7
+ In this guide, I'd like to share with you tips and tricks about ActiveRecord
8
+ preloading and how you can enhance it to the next level.
9
+
10
+ Let's start by describing the models.
11
+
12
+ ```ruby
13
+ # The model represents users in our application.
14
+ class User < ActiveRecord::Base
15
+ # Every user may have from 0 to many payments.
16
+ has_many :payments
17
+ end
18
+
19
+ # The model represents payments in our application.
20
+ class Payment < ActiveRecord::Base
21
+ # Every payment belongs to a user.
22
+ belongs_to :user
23
+ end
24
+ ```
25
+
26
+ Assuming we want to iterate over a group of users and check how many payments they have, we may do:
27
+
28
+ ```ruby
29
+ # The query we want to use to fetch users from the database.
30
+ users = User.all
31
+ # Iteration over selected users.
32
+ users.each do |user|
33
+ # Print amount of user's payments.
34
+ # This query will be called for every user, bringing an N+1 issue.
35
+ p user.payments.count
36
+ end
37
+ ```
38
+
39
+ We can fix the N+1 issue above in a second.
40
+ We need to add ActiveRecord's `includes` to the query that fetches users.
41
+
42
+ ```ruby
43
+ # The query to fetch users with preload payments for every selected user.
44
+ users = User.includes(:payments).all
45
+ ```
46
+
47
+ Then, we can iterate over the group again without the N+1 issue.
48
+
49
+ ```ruby
50
+ users.each do |user|
51
+ p user.payments.count
52
+ end
53
+ ```
54
+
55
+ Experienced with ActiveRecord person may notice that the iteration above still will have an N+1 issue.
56
+ The reason is the `.count` method and its behavior.
57
+ This issue brings us to the first tip.
58
+
59
+ ### Tip 1. `count` vs `size` vs `length`
60
+
61
+ - `count` - always queries the database with `COUNT` query;
62
+ - `size` - queries the database with `COUNT` only when there is no preloaded data, returns array length otherwise;
63
+ - `length` - always returns array length, in case there is no data, load it first.
64
+
65
+ _Note:_ be careful with `size` as ordering is critical.
66
+
67
+ Meaning, for `user = User.first`
68
+
69
+ ```ruby
70
+ # Does `COUNT` query
71
+ user.payments.size
72
+ # Does `SELECT` query
73
+ user.payments.each { |payment| }
74
+ ```
75
+
76
+ is different from
77
+
78
+ ```ruby
79
+ # Does `SELECT` query
80
+ user.payments.each { |payment| }
81
+ # No query
82
+ user.payments.size
83
+ ```
84
+
85
+ You may notice that the above solution loads all payment information when the amount is only needed.
86
+ 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).
87
+
88
+ To use that, you need to add `payments_count` field to `users` table and adjust `Payment` model.
89
+
90
+ ```ruby
91
+ # Migration to add `payments_count` to `users` table.
92
+ class AddPaymentsCountToUsers < ActiveRecord::Migration
93
+ def change
94
+ add_column :users, :payments_count, :integer, default: 0, null: false
95
+ end
96
+ end
97
+
98
+ # Change `belongs_to` to have `counter_cache` option.
99
+ class Payment < ActiveRecord::Base
100
+ belongs_to :user, counter_cache: true
101
+ end
102
+ ```
103
+
104
+ _Note:_ avoid adding or removing payments from the database directly or through `insert_all`/`delete`/`delete_all` as
105
+ `counter_cache` is using ActiveRecord callbacks to update the field's value.
106
+
107
+ It's worth mentioning [counter_culture](https://github.com/magnusvk/counter_culture) alternative that has many features compared with the built-in `counter_cache`
108
+
109
+ ## Associations with arguments
110
+
111
+ Now, let's assume we want to fetch the number of payments in a time frame for every user in a group.
112
+
113
+ ```ruby
114
+ from = 1.months.ago
115
+ to = Time.current
116
+
117
+ # Query to fetch users.
118
+ users = User.all
119
+
120
+ users.each do |user|
121
+ # Print the number of payments in a time frame for every user.
122
+ # Database query will be triggered for every user, meaning it has an N+1 issue.
123
+ p user.payments.where(created_at: from...to).count
124
+ end
125
+ ```
126
+
127
+ ActiveRecord supports defining associations with arguments.
128
+
129
+ ```ruby
130
+ class User < ActiveRecord::Base
131
+ has_many :payments, -> (from, to) { where(created_at: from...to) }
132
+ end
133
+ ```
134
+
135
+ Unfortunately, such associations are not possible to preload with `includes`.
136
+ Gladly, there is a solution with [N1Loader](https://github.com/djezzzl/n1_loader/).
137
+
138
+ ```ruby
139
+ # Install gem dependencies.
140
+ require 'n1_loader/active_record'
141
+
142
+ class User < ActiveRecord::Base
143
+ n1_optimized :payments_count do
144
+ argument :from
145
+ argument :to
146
+
147
+ def perform(users)
148
+ # Fetch the payment number once for all users.
149
+ payments = Payment.where(user: users).where(created_at: from...to).group(:user_id).count
150
+
151
+ users.each do |user|
152
+ # Assign preloaded data to every user.
153
+ # Note: it doesn't use any promises.
154
+ fulfill(user, payments[user.id])
155
+ end
156
+ end
157
+ end
158
+ end
159
+
160
+ from = 1.month.ago
161
+ to = Time.current
162
+
163
+ # Preload `payments` N1Loader "association". Doesn't query the database yet.
164
+ users = User.includes(:payments_count).all
165
+
166
+ users.each do |user|
167
+ # Queries the database once, meaning has no N+1 issues.
168
+ p user.payments_count(from, to)
169
+ end
170
+ ```
171
+
172
+ Let's look at another example. Assuming we want to fetch the last payment for every user.
173
+ We can try to define scoped `has_one` association and use that.
174
+
175
+ ```ruby
176
+ class User < ActiveRecord::Base
177
+ has_one :last_payment, -> { order(id: :desc) }, class_name: 'Payment'
178
+ end
179
+ ```
180
+
181
+ We can see that preloading is working.
182
+
183
+ ```ruby
184
+ users = User.includes(:last_payment)
185
+
186
+ users.each do |user|
187
+ # No N+1. Last payment was returned.
188
+ p user.last_payment
189
+ end
190
+ ```
191
+
192
+ At first glance, we may think everything is alright. Unfortunately, it is not.
193
+
194
+ ### Tip 2. Enforce `has_one` associations on the database level
195
+
196
+ ActiveRecord, fetches all available payments for every user with provided order and then assigns only first payment to the association.
197
+ First, such querying is inefficient as we load many redundant information.
198
+ But most importantly, this association may lead to big issues. Other engineers may use it, for example,
199
+ for `joins(:last_payment)`. Assuming that association has strict agreement on the database level that
200
+ a user may have none or a single payment in the database. Apparently, it may not be the case, and some queries
201
+ will return unexpected data.
202
+
203
+ Described issues may be found with [DatabaseConsistency](https://github.com/djezzzl/database_consistency).
204
+
205
+ Back to the task, we can solve it with [N1Loader](https://github.com/djezzzl/n1_loader) in the following way
206
+
207
+ ```ruby
208
+ require 'n1_loader/active_record'
209
+
210
+ class User < ActiveRecord::Base
211
+ n1_optimized :last_payment do |users|
212
+ subquery = Payment.select('MAX(id)').where(user: users)
213
+ payments = Payment.where(id: subquery).index_by(&:user_id)
214
+
215
+ users.each do |user|
216
+ fulfill(user, payments[user.id])
217
+ end
218
+ end
219
+ end
220
+
221
+ users = User.includes(:last_payment).all
222
+
223
+ users.each do |user|
224
+ # Queries the database once, meaning no N+1.
225
+ p user.last_payment
226
+ end
227
+ ```
228
+
229
+ 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.
230
+ Gladly, there is a simple solution! [ArLazyPreload](https://github.com/DmitryTsepelev/ar_lazy_preload) will make N+1 disappear just by enabling it.
231
+ 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.
232
+ And it works with ActiveRecord and N1Loader perfectly!
233
+
234
+ Let's look at the example.
235
+
236
+ ```ruby
237
+ # Require N1Loader with ArLazyPreload integration
238
+ require 'n1_loader/ar_lazy_preload'
239
+
240
+ # Enable ArLazyPreload globally, so you don't need to care about `includes` anymore
241
+ ArLazyPreload.config.auto_preload = true
242
+
243
+ class User < ActiveRecord::Base
244
+ has_many :payments
245
+
246
+ n1_optimized :last_payment do |users|
247
+ subquery = Payment.select('MAX(id)').where(user: users)
248
+ payments = Payment.where(id: subquery).index_by(&:user_id)
249
+
250
+ users.each do |user|
251
+ fulfill(user, payments[user.id])
252
+ end
253
+ end
254
+ end
255
+
256
+ # no need to specify `includes`
257
+ users = User.all
258
+
259
+ users.each do |user|
260
+ p user.payments # no N+1
261
+ p user.last_payment # no N+1
262
+ end
263
+ ```
264
+
265
+ 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.
266
+ It works great with GraphQL API too. Give it and try and share your feedback!
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Returns cached N1Loader::LoaderCollection from context for a loader.
4
+ # In case there is none yet, saves passed block to a cache.
5
+ ArLazyPreload::Contexts::BaseContext.define_method :fetch_n1_loader_collection do |loader, &block|
6
+ (@n1_loader_collections ||= {})[loader] ||= block.call
7
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module N1Loader
4
4
  module ArLazyPreload
5
- # Context adapter for N1Loader
5
+ # Context adapter for injected N1Loader loaders.
6
6
  class ContextAdapter
7
7
  attr_reader :context
8
8
 
@@ -12,12 +12,15 @@ module N1Loader
12
12
  @context = context
13
13
  end
14
14
 
15
+ # Assign initialized preloader to +association_name+ in case it wasn't yet preloaded within the given context.
15
16
  def try_preload_lazily(association_name)
16
17
  return unless context&.send(:association_needs_preload?, association_name)
17
18
 
18
19
  perform_preloading(association_name)
19
20
  end
20
21
 
22
+ # Initialize preloader for +association_name+ with context builder callback.
23
+ # The callback will be executed when on records load.
21
24
  def perform_preloading(association_name)
22
25
  context_setup = lambda { |records|
23
26
  AssociatedContextBuilder.prepare(
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Raised when a single object without ArLazyPreload context was passed to an isolated loader.
4
+ N1Loader::Loader::MissingArLazyPreloadContext = Class.new(StandardError)
5
+
6
+ # Defines a singleton method method that allows isolated loaders
7
+ # to use ArLazyPreload context without passing sibling records.
8
+ N1Loader::Loader.define_singleton_method(:for) do |element, **args|
9
+ # It is required to have an ArLazyPreload context defined
10
+ if !element.respond_to?(:lazy_preload_context) || element.lazy_preload_context.nil?
11
+ raise N1Loader::Loader::MissingArLazyPreloadContext
12
+ end
13
+
14
+ # Fetch or initialize loader from ArLazyPreload context
15
+ loader_collection = element.lazy_preload_context.fetch_n1_loader_collection(self) do
16
+ context_setup = lambda { |records|
17
+ N1Loader::ArLazyPreload::AssociatedContextBuilder.prepare(
18
+ parent_context: element.lazy_preload_context,
19
+ association_name: "cached_n1_loader_collection_#{self}".downcase.to_sym,
20
+ records: records
21
+ )
22
+ }
23
+
24
+ N1Loader::LoaderCollection.new(self, element.lazy_preload_context.records).tap do |collection|
25
+ collection.context_setup = context_setup
26
+ end
27
+ end
28
+
29
+ # Fetch value from loader
30
+ loader_collection.with(**args).for(element)
31
+ end
@@ -14,6 +14,8 @@ require_relative "ar_lazy_preload/associated_context_builder"
14
14
  require_relative "ar_lazy_preload/loader_collection_patch"
15
15
  require_relative "ar_lazy_preload/preloader_patch"
16
16
  require_relative "ar_lazy_preload/loader_patch"
17
+ require_relative "ar_lazy_preload/loader"
18
+ require_relative "ar_lazy_preload/context"
17
19
 
18
20
  N1Loader::Loadable::ClassMethods.prepend(N1Loader::ArLazyPreload::Loadable::ClassMethods)
19
21
  N1Loader::Preloader.prepend(N1Loader::ArLazyPreload::PreloaderPatch)
@@ -23,7 +23,9 @@ module N1Loader
23
23
 
24
24
  @arguments ||= []
25
25
 
26
- define_method(name) { args[name] ||= opts[:default]&.call }
26
+ define_method(name) do
27
+ args.fetch(name) { args[name] = opts[:default]&.call }
28
+ end
27
29
 
28
30
  @arguments << opts.merge(name: name)
29
31
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module N1Loader
4
- VERSION = "1.5.0"
4
+ VERSION = "1.6.0"
5
5
  end
data/n1_loader.gemspec CHANGED
@@ -14,8 +14,8 @@ Gem::Specification.new do |spec|
14
14
  spec.required_ruby_version = ">= 2.5.0"
15
15
 
16
16
  spec.metadata["homepage_uri"] = spec.homepage
17
- spec.metadata["source_code_uri"] = "https://github.com/djezzzl/database_consistency"
18
- spec.metadata["changelog_uri"] = "https://github.com/djezzzl/database_consistency/master/CHANGELOG.md"
17
+ spec.metadata["source_code_uri"] = "https://github.com/djezzzl/n1_loader"
18
+ spec.metadata["changelog_uri"] = "https://github.com/djezzzl/n1_loader/master/CHANGELOG.md"
19
19
 
20
20
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
21
21
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: n1_loader
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Evgeniy Demin
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-05-01 00:00:00.000000000 Z
11
+ date: 2022-10-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -144,6 +144,8 @@ extensions: []
144
144
  extra_rdoc_files: []
145
145
  files:
146
146
  - ".circleci/config.yml"
147
+ - ".github/workflows/rubocop.yml"
148
+ - ".github/workflows/tests.yml"
147
149
  - ".gitignore"
148
150
  - ".rspec"
149
151
  - ".rubocop.yml"
@@ -162,6 +164,7 @@ files:
162
164
  - bin/setup
163
165
  - examples/active_record_integration.rb
164
166
  - examples/ar_lazy_integration.rb
167
+ - examples/ar_lazy_integration_with_isolated_loader.rb
165
168
  - examples/arguments_support.rb
166
169
  - examples/context/service.rb
167
170
  - examples/context/setup_ar_lazy.rb
@@ -173,6 +176,7 @@ files:
173
176
  - examples/reloading.rb
174
177
  - examples/shared_loader.rb
175
178
  - examples/single_case.rb
179
+ - guides/enhanced-activerecord.md
176
180
  - lib/n1_loader.rb
177
181
  - lib/n1_loader/active_record.rb
178
182
  - lib/n1_loader/active_record/associations_preloader_v5.rb
@@ -183,8 +187,10 @@ files:
183
187
  - lib/n1_loader/active_record/loader_collection.rb
184
188
  - lib/n1_loader/ar_lazy_preload.rb
185
189
  - lib/n1_loader/ar_lazy_preload/associated_context_builder.rb
190
+ - lib/n1_loader/ar_lazy_preload/context.rb
186
191
  - lib/n1_loader/ar_lazy_preload/context_adapter.rb
187
192
  - lib/n1_loader/ar_lazy_preload/loadable.rb
193
+ - lib/n1_loader/ar_lazy_preload/loader.rb
188
194
  - lib/n1_loader/ar_lazy_preload/loader_collection_patch.rb
189
195
  - lib/n1_loader/ar_lazy_preload/loader_patch.rb
190
196
  - lib/n1_loader/ar_lazy_preload/preloader_patch.rb
@@ -199,8 +205,8 @@ licenses:
199
205
  - MIT
200
206
  metadata:
201
207
  homepage_uri: https://github.com/djezzzl/n1_loader
202
- source_code_uri: https://github.com/djezzzl/database_consistency
203
- changelog_uri: https://github.com/djezzzl/database_consistency/master/CHANGELOG.md
208
+ source_code_uri: https://github.com/djezzzl/n1_loader
209
+ changelog_uri: https://github.com/djezzzl/n1_loader/master/CHANGELOG.md
204
210
  post_install_message:
205
211
  rdoc_options: []
206
212
  require_paths:
@@ -216,7 +222,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
216
222
  - !ruby/object:Gem::Version
217
223
  version: '0'
218
224
  requirements: []
219
- rubygems_version: 3.1.6
225
+ rubygems_version: 3.2.33
220
226
  signing_key:
221
227
  specification_version: 4
222
228
  summary: Loader to solve N+1 issue for good.