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 +4 -4
- data/.circleci/config.yml +0 -14
- data/.github/workflows/rubocop.yml +23 -0
- data/.github/workflows/tests.yml +62 -0
- data/.rubocop.yml +2 -1
- data/CHANGELOG.md +8 -0
- data/README.md +89 -0
- data/examples/ar_lazy_integration.rb +3 -4
- data/examples/ar_lazy_integration_with_isolated_loader.rb +39 -0
- data/guides/enhanced-activerecord.md +266 -0
- data/lib/n1_loader/ar_lazy_preload/context.rb +7 -0
- data/lib/n1_loader/ar_lazy_preload/context_adapter.rb +4 -1
- data/lib/n1_loader/ar_lazy_preload/loader.rb +31 -0
- data/lib/n1_loader/ar_lazy_preload.rb +2 -0
- data/lib/n1_loader/core/loader.rb +3 -1
- data/lib/n1_loader/version.rb +1 -1
- data/n1_loader.gemspec +2 -2
- metadata +11 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3d914dd09225515a579945ab616916bf1ad446101c5051c4cf1b72c46d38c107
|
4
|
+
data.tar.gz: a304c32e35294c5646fafe5c65a5de6c637e80e080d80ac16f957d77a3ebe4c6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
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
|
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)
|
data/lib/n1_loader/version.rb
CHANGED
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/
|
18
|
-
spec.metadata["changelog_uri"] = "https://github.com/djezzzl/
|
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.
|
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-
|
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/
|
203
|
-
changelog_uri: https://github.com/djezzzl/
|
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.
|
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.
|