n1_loader 1.4.2 → 1.5.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: f72c7428f78489c25222fb2ab8d56fde519c680fdc010115b952637c2291ca93
4
- data.tar.gz: 7149852481ffed8d1fd0fc25e28fc045eda0a0f2936e748461dd7f43d4f709f1
3
+ metadata.gz: bf26363e90cf5f0b6759776698445183f4abe8442bf366e7109e1cada18b1eaa
4
+ data.tar.gz: e082a72dc1f910262fbc73040b484ae28e437beee866ca42277a7556d989072a
5
5
  SHA512:
6
- metadata.gz: 0d0802914b84d4c25a97c8c328ff2ae4453cf8fcd4eee87de1955b06b810c874d9982498b5fc80ecf1fa77238971e51ef5f162f809914876a1811084b366ddfe
7
- data.tar.gz: fa001ab34b7165ee35ba31ef449c837418d538e52977ee984b38e35b767df06c05ef0cb01949c9290c17ae40bcd3e3644f00374cf7f66d7e6811f094515c37da
6
+ metadata.gz: 52993103afc0075d9d71088e6d7e60925ea1740176ee31829acb5d42009a38955b25d8e9124a17fff981b4735f443c04d3532cbea3186850bffc2908f0e40522
7
+ data.tar.gz: fdcc21a626ff199ccf4a9af7bd860516745693614404c44159414b27601750ea476de8eae593f7bade3782425b638f784db096e6dcdaafa58c71382f3a08b979
data/.circleci/config.yml CHANGED
@@ -106,7 +106,8 @@ workflows:
106
106
  ]
107
107
  activerecord-gemfile: [
108
108
  "ar_5_latest",
109
- "ar_6_latest"
109
+ "ar_6_latest",
110
+ "ar_7_latest"
110
111
  ]
111
112
  ar_lazy_preload-gemfile: [
112
113
  "ar_lazy_preload_0.6.1",
@@ -115,21 +116,40 @@ workflows:
115
116
  exclude:
116
117
  # Ruby 2.5 and AR Lazy Preload 1+
117
118
  - ruby-version: "2.5"
118
- activerecord-gemfile: "ar_5_latest"
119
119
  ar_lazy_preload-gemfile: "ar_lazy_preload_master"
120
+ activerecord-gemfile: "ar_5_latest"
120
121
 
121
122
  - ruby-version: "2.5"
123
+ ar_lazy_preload-gemfile: "ar_lazy_preload_master"
122
124
  activerecord-gemfile: "ar_6_latest"
125
+
126
+ - ruby-version: "2.5"
123
127
  ar_lazy_preload-gemfile: "ar_lazy_preload_master"
128
+ activerecord-gemfile: "ar_7_latest"
124
129
 
125
130
  # AR 5 and ruby 3+
126
131
  - ruby-version: "latest"
127
132
  activerecord-gemfile: "ar_5_latest"
128
133
  ar_lazy_preload-gemfile: "ar_lazy_preload_0.6.1"
134
+
129
135
  - ruby-version: "latest"
130
136
  activerecord-gemfile: "ar_5_latest"
131
137
  ar_lazy_preload-gemfile: "ar_lazy_preload_master"
132
138
 
139
+ # AR 7 and ar_lazy_preload < 1
140
+ - ruby-version: "2.5"
141
+ activerecord-gemfile: "ar_7_latest"
142
+ ar_lazy_preload-gemfile: "ar_lazy_preload_0.6.1"
143
+
144
+ - ruby-version: "2.7"
145
+ activerecord-gemfile: "ar_7_latest"
146
+ ar_lazy_preload-gemfile: "ar_lazy_preload_0.6.1"
147
+
148
+ - ruby-version: "latest"
149
+ activerecord-gemfile: "ar_7_latest"
150
+ ar_lazy_preload-gemfile: "ar_lazy_preload_0.6.1"
151
+
152
+
133
153
  name: ruby-<< matrix.ruby-version >>-<< matrix.activerecord-gemfile >>-<< matrix.ar_lazy_preload-gemfile >>
134
154
  - rubocop:
135
155
  requires:
data/.gitignore CHANGED
@@ -8,4 +8,5 @@
8
8
  /tmp/
9
9
  /.idea
10
10
  .rspec_status
11
- Gemfile.lock
11
+ Gemfile.lock
12
+ .ruby-version
data/.rubocop.yml CHANGED
@@ -1,5 +1,7 @@
1
1
  AllCops:
2
2
  TargetRubyVersion: 2.5
3
+ Exclude:
4
+ - examples/**/*
3
5
 
4
6
  Style/StringLiterals:
5
7
  Enabled: true
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## [1.5.0] - 2022/05/01
2
+
3
+ - Add support of Rails 7
4
+
5
+ ## [1.4.4] - 2022/04/29
6
+
7
+ - Inject `N1Loader::Loadable` to `ActiveRecord::Base` automatically
8
+ - Make `reload` to call `n1_clear_cache`
9
+
10
+ ## [1.4.3] - 2022-04-13
11
+
12
+ - Add `default` support to arguments
13
+
1
14
  ## [1.4.2] - 2022-03-01
2
15
 
3
16
  - Add n1_clear_cache method which is useful for cases like reload in ActiveRecord
data/README.md CHANGED
@@ -3,298 +3,147 @@
3
3
  [![CircleCI][1]][2]
4
4
  [![Gem Version][3]][4]
5
5
 
6
- Are you tired of fixing [N+1 issues][7]? Does it feel unnatural to you to fix it case by case in places where you need the data?
7
- We have a solution for you!
6
+ N1Loader is designed to provide a simple way for avoiding [N+1 issues][7] of any kind.
7
+ For example, it can help with resolving N+1 for:
8
+ - database querying (most common case)
9
+ - 3rd party service calls
10
+ - complex calculations
11
+ - and many more
8
12
 
9
- [N1Loader][8] is designed to solve the issue for good!
13
+ > [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.
10
14
 
11
- It has many benefits:
12
- - it can be [isolated](#isolated-loaders)
13
- - it loads data [lazily](#lazy-loading)
14
- - it supports [shareable loaders](#shareable-loaders) between multiple classes
15
- - it supports [reloading](#reloading)
16
- - it supports optimized [single object loading](#optimized-single-case)
17
- - it supports [arguments](#arguments)
18
- - it has an integration with [ActiveRecord][5] which makes it brilliant ([example](#activerecord))
19
- - it has an integration with [ArLazyPreload][6] which makes it excellent ([example](#arlazypreload))
15
+ ## Killer feature for GraphQL API
20
16
 
21
- ... and even more features to come! Stay tuned!
17
+ N1Loader in combination with [ArLazyPreload][6] is a killer feature for your GraphQL API.
18
+ 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!
22
19
 
23
- ## Installation
24
-
25
- Add this line to your application's Gemfile:
26
-
27
- ```ruby
28
- gem 'n1_loader'
29
- ```
30
-
31
- You can add integration with [ActiveRecord][5] by:
32
- ```ruby
33
- gem 'n1_loader', require: 'n1_loader/active_record'
34
- ```
35
-
36
- You can add the integration with [ActiveRecord][5] and [ArLazyPreload][6] by:
37
20
  ```ruby
38
21
  gem 'n1_loader', require: 'n1_loader/ar_lazy_preload'
39
22
  ```
40
23
 
41
- ## Usage
24
+ ## Enhance [ActiveRecord][5]
42
25
 
43
- ```ruby
44
- class User
45
- include N1Loader::Loadable
46
-
47
- # with inline loader
48
- n1_optimized :orders_count do |users|
49
- orders_per_user = Order.where(user: users).group(:user_id).count
50
-
51
- users.each { |user| fulfill(user, orders_per_user[user.id]) }
52
- end
53
- end
54
-
55
- # For single object
56
- user = User.new
57
- user.orders_count
26
+ 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!
58
27
 
59
- # For multiple objects without N+1
60
- users = [User.new, User.new]
61
- N1Loader::Preloader.new(users).preload(:orders_count)
62
- users.map(&:orders_count)
28
+ ```ruby
29
+ gem 'n1_loader', require: 'n1_loader/active_record'
63
30
  ```
64
31
 
65
- ### Lazy loading
32
+ Are you ready to forget about N+1 once and for all? Install [ArLazyPreload][6] and see dreams come true!
66
33
 
67
34
  ```ruby
68
- class User
69
- include N1Loader::Loadable
70
-
71
- # with inline loader
72
- n1_optimized :orders_count do |users|
73
- orders_per_user = Order.where(user: users).group(:user_id).count
74
-
75
- users.each { |user| fulfill(user, orders_per_user[user.id]) }
76
- end
77
- end
78
-
79
- user = User.new # => nothing was done for loading
80
- user.orders_count # => first time loading
81
-
82
- users = [User.new, User.new] # => nothing was done for loading
83
- N1Loader::Preloader.new([users]).preload(:orders_count) # => we only initialized loader but didn't perform it yet
84
- users.map(&:orders_count) # => loading has happen for the first time (without N+1)
35
+ gem 'n1_loader', require: 'n1_loader/ar_lazy_preload'
85
36
  ```
86
37
 
38
+ ## Standalone mode
87
39
 
88
- ### Shareable loaders
40
+ Are you not working with [ActiveRecord][5]? N1Loader is ready to be used as standalone solution! ([full snippet](examples/core.rb))
89
41
 
90
42
  ```ruby
91
- class OrdersCountLoader < N1Loader::Loader
92
- def perform(users)
93
- orders_per_user = Order.where(user: users).group(:user_id).count
94
-
95
- users.each { |user| fulfill(user, orders_per_user[user.id]) }
96
- end
97
- end
98
-
99
- class User
100
- include N1Loader::Loadable
101
-
102
- n1_optimized :orders_count, OrdersCountLoader
103
- end
104
-
105
- class Customer
106
- include N1Loader::Loadable
107
-
108
- n1_optimized :orders_count, OrdersCountLoader
109
- end
110
-
111
- User.new.orders_count # => works
112
- Customer.new.orders_count # => works
43
+ gem 'n1_loader'
113
44
  ```
114
45
 
115
- ### Reloading
116
-
117
- ```ruby
118
- class User
119
- include N1Loader::Loadable
120
-
121
- # with inline loader
122
- n1_optimized :orders_count do |users|
123
- orders_per_user = Order.where(user: users).group(:user_id).count
124
-
125
- users.each { |user| fulfill(user, orders_per_user[user.id]) }
126
- end
127
- end
128
-
129
- user = User.new
130
- user.orders_count # => loader is executed first time and value was cached
131
- user.orders_count(reload: true) # => loader is executed again and a new value was cached
132
- # or
133
- user.n1_clear_cache
134
- user.orders_count
135
-
136
- users = [User.new, User.new]
137
- N1Loader::Preloader.new(users).preload(:orders_count) # => loader was initialized but not yet executed
138
- users.map(&:orders_count) # => loader was executed first time without N+1 issue and values were cached
46
+ ## How to use it?
139
47
 
140
- N1Loader::Preloader.new(users).preload(:orders_count) # => loader was initialized again but not yet executed
141
- users.map(&:orders_count) # => new loader was executed first time without N+1 issue and new values were cached
142
- ```
48
+ N1Loader provides DSL that allows you to define N+1 ready loaders that can
49
+ be injected into your objects in a way that you can avoid N+1 issues.
143
50
 
144
- ### Isolated loaders
51
+ > _Disclaimer_: examples below are working but designed to show N1Loader potentials only.
52
+ In real live applications, N1Loader can be applied anywhere and in more [elegant way](examples/isolated_loader.rb).
145
53
 
54
+ Let's look at simple example below ([full snippet](examples/active_record_integration.rb)):
146
55
  ```ruby
147
- class IsolatedLoader < N1Loader::Loader
148
- def perform(elements)
149
- elements.each { |element| fulfill(element, [element]) }
56
+ class User < ActiveRecord::Base
57
+ has_many :payments
58
+
59
+ n1_optimized :payments_total do |users|
60
+ total_per_user =
61
+ Payment.group(:user_id)
62
+ .where(user: users)
63
+ .sum(:amount)
64
+ .tap { |h| h.default = 0 }
65
+
66
+ users.each do |user|
67
+ total = total_per_user[user.id]
68
+ fulfill(user, total)
69
+ end
150
70
  end
151
71
  end
152
72
 
153
- objects = [1, 2, 3, 4]
154
- loader = IsolatedLoader.new(objects)
155
- objects.each do |object|
156
- loader.for(object) # => it has no N+1 and it doesn't require to be injected in the class
73
+ class Payment < ActiveRecord::Base
74
+ belongs_to :user
75
+
76
+ validates :amount, presence: true
157
77
  end
158
- ```
159
78
 
160
- ### Optimized single case
79
+ # A user has many payments.
80
+ # Assuming, we want to know for group of users, what is a total of their payments, we can do the following:
161
81
 
162
- ```ruby
163
- class User
164
- include N1Loader::Loadable
82
+ # Has N+1 issue
83
+ p User.all.map { |user| user.payments.sum(&:amount) }
165
84
 
166
- n1_optimized :orders_count do # no arguments passed to the block, so we can override both perform and single.
167
- def perform(users)
168
- orders_per_user = Order.where(user: users).group(:user_id).count
85
+ # Has no N+1 but we load too many data that we don't actually need
86
+ p User.all.includes(:payments).map { |user| user.payments.sum(&:amount) }
169
87
 
170
- users.each { |user| fulfill(user, orders_per_user[user.id]) }
171
- end
172
-
173
- # Optimized for single object loading
174
- def single(user)
175
- user.orders.count
176
- end
177
- end
178
- end
179
-
180
- user = User.new
181
- user.orders_count # single will be used here
182
-
183
- users = [User.new, User.new]
184
- N1Loader::Preloader.new(users).preload(:orders_count)
185
- users.map(&:orders_count) # perform will be used once without N+1
88
+ # Has no N+1 and we load only what we need
89
+ p User.all.includes(:payments_total).map { |user| user.payments_total }
186
90
  ```
187
91
 
188
- ### Arguments
92
+ Let's assume now, that we want to calculate the total of payments for the given period for a group of users.
93
+ N1Loader can do that as well! ([full snippet](examples/arguments_support.rb))
189
94
 
190
95
  ```ruby
191
- class User
192
- include N1Loader::Loadable
193
-
194
- n1_optimized :orders_count do
195
- argument :type
196
-
197
- def perform(users)
198
- orders_per_user = Order.where(type: type, user: users).group(:user_id).count
199
-
200
- users.each { |user| fulfill(user, orders_per_user[user.id]) }
201
- end
202
- end
203
- end
204
-
205
- user = User.new
206
- user.orders_count(type: :gifts) # The loader will be performed first time for this argument
207
- user.orders_count(type: :sales) # The loader will be performed first time for this argument
208
- user.orders_count(type: :gifts) # The cached value will be used
209
-
210
- users = [User.new, User.new]
211
- N1Loader::Preloader.new(users).preload(:orders_count)
212
- users.map { |user| user.orders_count(type: :gifts) } # No N+1 here
213
- ```
96
+ class User < ActiveRecord::Base
97
+ has_many :payments
214
98
 
215
- _Note_: By default, we use `arguments.map(&:object_id)` to identify arguments but in some cases,
216
- you may want to override it, for example:
99
+ n1_optimized :payments_total do
100
+ argument :from
101
+ argument :to
217
102
 
218
- ```ruby
219
- class User
220
- include N1Loader::Loadable
221
-
222
- n1_optimized :orders_count do
223
- argument :sale
224
-
225
- cache_key { sale.id }
226
-
227
103
  def perform(users)
228
- orders_per_user = Order.where(sale: sale, user: users).group(:user_id).count
229
-
230
- users.each { |user| fulfill(user, orders_per_user[user.id]) }
104
+ total_per_user =
105
+ Payment
106
+ .group(:user_id)
107
+ .where(created_at: from..to)
108
+ .where(user: users)
109
+ .sum(:amount)
110
+ .tap { |h| h.default = 0 }
111
+
112
+ users.each do |user|
113
+ total = total_per_user[user.id]
114
+ fulfill(user, total)
115
+ end
231
116
  end
232
117
  end
233
118
  end
234
119
 
235
- user = User.new
236
- user.orders_count(sale: Sale.first) # perform will be executed and value will be cached
237
- user.orders_count(sale: Sale.first) # the cached value will be returned
238
- ```
239
-
240
-
241
- ## Integrations
242
-
243
- ### [ActiveRecord][5]
244
-
245
- _Note_: Rails 7 support is coming soon! Stay tuned!
246
-
247
- ```ruby
248
- class User < ActiveRecord::Base
249
- include N1Loader::Loadable
250
-
251
- n1_optimized :orders_count do |users|
252
- orders_per_user = Order.where(user: users).group(:user_id).count
120
+ class Payment < ActiveRecord::Base
121
+ belongs_to :user
253
122
 
254
- users.each { |user| fulfill(user, orders_per_user[user.id]) }
255
- end
123
+ validates :amount, presence: true
256
124
  end
257
125
 
258
- # For single user
259
- user = User.first
260
- user.orders_count
261
-
262
- # For many users without N+1
263
- User.limit(5).includes(:orders_count).map(&:orders_count)
126
+ # Has N+1
127
+ p User.all.map { |user| user.payments.select { |payment| payment.created_at >= from && payment.created_at <= to }.sum(&:amount) }
264
128
 
265
- # or with explicit preloader
266
- users = User.limit(5).to_a
267
- N1Loader::Preloader.new(users).preload(:orders_count)
129
+ # Has no N+1 but we load too many data that we don't need
130
+ p User.all.includes(:payments).map { |user| user.payments.select { |payment| payment.created_at >= from && payment.created_at <= to }.sum(&:amount) }
268
131
 
269
- # No N+1 here
270
- users.map(&:orders_count)
132
+ # Has no N+1 and calculation is the most efficient
133
+ p User.all.includes(:payments_total).map { |user| user.payments_total(from: from, to: to) }
271
134
  ```
272
135
 
273
- ### [ArLazyPreload][6]
274
-
275
- ```ruby
276
- class User < ActiveRecord::Base
277
- include N1Loader::Loadable
278
-
279
- n1_optimized :orders_count do |users|
280
- orders_per_user = Order.where(user: users).group(:user_id).count
136
+ ## Features and benefits
281
137
 
282
- users.each { |user| fulfill(user, orders_per_user[user.id]) }
283
- end
284
- end
285
-
286
- # For single user
287
- user = User.first
288
- user.orders_count
289
-
290
- # For many users without N+1
291
- User.lazy_preload(:orders_count).all.map(&:orders_count)
292
- # or
293
- User.preload_associations_lazily.all.map(&:orders_count)
294
- # or
295
- ArLazyPreload.config.auto_preload = true
296
- User.all.map(:orders_count)
297
- ```
138
+ - N1Loader doesn't use Promises which means it's easy to debug
139
+ - Doesn't require injection to objects, can be used in [isolation](examples/isolated_loader.rb)
140
+ - Loads data [lazily](examples/lazy_loading.rb)
141
+ - Loaders can be [shared](examples/shared_loader.rb) between multiple classes
142
+ - Loaded data can be [re-fetched](examples/reloading.rb)
143
+ - Loader can be optimized for [single cases](examples/single_case.rb)
144
+ - Loader support [arguments](examples/arguments_support.rb)
145
+ - Has [integration](examples/active_record_integration.rb) with [ActiveRecord][5] which makes it brilliant
146
+ - Has [integration](examples/ar_lazy_integration.rb) with [ArLazyPreload][6] which makes it excellent
298
147
 
299
148
  ## Contributing
300
149
 
@@ -324,4 +173,4 @@ Copyright (c) Evgeniy Demin. See [LICENSE.txt](LICENSE.txt) for further details.
324
173
  [5]: https://github.com/rails/rails/tree/main/activerecord
325
174
  [6]: https://github.com/DmitryTsepelev/ar_lazy_preload
326
175
  [7]: https://stackoverflow.com/questions/97197/what-is-the-n1-selects-problem-in-orm-object-relational-mapping
327
- [8]: https://github.com/djezzzl/n1_loader
176
+ [8]: https://github.com/djezzzl/n1_loader
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ gem "activerecord", "~> 7"
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "n1_loader/active_record"
4
+
5
+ require_relative 'context/setup_database'
6
+
7
+ class User < ActiveRecord::Base
8
+ has_many :payments
9
+
10
+ n1_optimized :payments_total do |users|
11
+ total_per_user = Payment.group(:user_id).where(user: users).sum(:amount).tap { |h| h.default = 0 }
12
+
13
+ users.each do |user|
14
+ total = total_per_user[user.id]
15
+ fulfill(user, total)
16
+ end
17
+ end
18
+ end
19
+
20
+ class Payment < ActiveRecord::Base
21
+ belongs_to :user
22
+
23
+ validates :amount, presence: true
24
+ end
25
+
26
+ fill_database
27
+
28
+ # Has N+1
29
+ p User.all.map { |user| user.payments.sum(&:amount) }
30
+ # Has no N+1 but we load too many data that we don't need
31
+ p User.all.includes(:payments).map { |user| user.payments.sum(&:amount) }
32
+ # Has no N+1 and calculation is the most efficient
33
+ p User.all.includes(:payments_total).map(&:payments_total)
@@ -0,0 +1,37 @@
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 User < ActiveRecord::Base
9
+ has_many :payments
10
+
11
+ n1_optimized :payments_total do |users|
12
+ total_per_user = Payment.group(:user_id).where(user: users).sum(:amount).tap { |h| h.default = 0 }
13
+
14
+ users.each do |user|
15
+ total = total_per_user[user.id]
16
+ fulfill(user, total)
17
+ end
18
+ end
19
+ end
20
+
21
+ class Payment < ActiveRecord::Base
22
+ belongs_to :user
23
+
24
+ validates :amount, presence: true
25
+ end
26
+
27
+ fill_database
28
+
29
+ # Has N+1
30
+ p User.all.map { |user| user.payments.sum(&:amount) }
31
+
32
+ # Has no N+1 but we load too many data that we don't need
33
+ p User.preload_associations_lazily.map(&:payments_total)
34
+
35
+ # Has no N+1 and calculation is the most efficient
36
+ ArLazyPreload.config.auto_preload = true
37
+ User.all.map(&:payments_total)
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "n1_loader/active_record"
4
+
5
+ require_relative 'context/setup_database'
6
+
7
+ class User < ActiveRecord::Base
8
+ has_many :payments
9
+
10
+ n1_optimized :payments_total do
11
+ # Arguments can be:
12
+ # argument :something, optional: true
13
+ # argument :something, default: -> { 100 }
14
+ #
15
+ # Note: do not use mutable (mostly timing related) defaults like:
16
+ # argument :from, default -> { 2.minutes.from_now }
17
+ # because such values will be unique for every loader call which will make N+1 issue stay
18
+ argument :from
19
+ argument :to
20
+
21
+ # This is used to define logic how loaders are compared to each other
22
+ # default is:
23
+ # cache_key { *arguments.map(&:object_id) }
24
+ cache_key { [from, to] }
25
+
26
+ def perform(users)
27
+ total_per_user =
28
+ Payment
29
+ .group(:user_id)
30
+ .where(created_at: from..to)
31
+ .where(user: users)
32
+ .sum(:amount)
33
+ .tap { |h| h.default = 0 }
34
+
35
+ users.each do |user|
36
+ total = total_per_user[user.id]
37
+ fulfill(user, total)
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ class Payment < ActiveRecord::Base
44
+ belongs_to :user
45
+
46
+ validates :amount, presence: true
47
+ end
48
+
49
+ fill_database
50
+
51
+ from = 2.days.ago
52
+ to = 1.day.ago
53
+
54
+ # Has N+1
55
+ p User.all.map { |user|
56
+ user.payments.select do |payment|
57
+ payment.created_at >= from && payment.created_at <= to
58
+ end.sum(&:amount)
59
+ }
60
+ # Has no N+1 but we load too many data that we don't need
61
+ p User.all.includes(:payments).map { |user|
62
+ user.payments.select do |payment|
63
+ payment.created_at >= from && payment.created_at <= to
64
+ end.sum(&:amount)
65
+ }
66
+ # Has no N+1 and calculation is the most efficient
67
+ p User.all.includes(:payments_total).map { |user| user.payments_total(from: from, to: to) }
@@ -0,0 +1,20 @@
1
+ # 3rd party service, or database, or anything else that can perform in batches
2
+ class Service
3
+ def self.count
4
+ @count ||= 0
5
+ end
6
+
7
+ def self.increase!
8
+ @count = (@count || 0) + 1
9
+ end
10
+
11
+ def self.receive(*users)
12
+ increase!
13
+
14
+ users.flatten.map(&:object_id)
15
+ end
16
+
17
+ def self.single(user)
18
+ user.object_id
19
+ end
20
+ end
@@ -0,0 +1,15 @@
1
+ ActiveSupport.on_load(:active_record) do
2
+ ActiveRecord::Base.include(ArLazyPreload::Base)
3
+
4
+ ActiveRecord::Relation.prepend(ArLazyPreload::Relation)
5
+ ActiveRecord::AssociationRelation.prepend(ArLazyPreload::AssociationRelation)
6
+ ActiveRecord::Relation::Merger.prepend(ArLazyPreload::Merger)
7
+
8
+ [
9
+ ActiveRecord::Associations::CollectionAssociation,
10
+ ActiveRecord::Associations::Association
11
+ ].each { |klass| klass.prepend(ArLazyPreload::Association) }
12
+
13
+ ActiveRecord::Associations::CollectionAssociation.prepend(ArLazyPreload::CollectionAssociation)
14
+ ActiveRecord::Associations::CollectionProxy.prepend(ArLazyPreload::CollectionProxy)
15
+ end
@@ -0,0 +1,26 @@
1
+ require "sqlite3"
2
+
3
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
4
+ ActiveRecord::Base.connection.tables.each do |table|
5
+ ActiveRecord::Base.connection.drop_table(table, force: :cascade)
6
+ end
7
+ ActiveRecord::Schema.verbose = false
8
+ ActiveRecord::Base.logger = Logger.new($stdout)
9
+
10
+ ActiveRecord::Schema.define(version: 1) do
11
+ create_table(:payments) do |t|
12
+ t.belongs_to :user
13
+ t.integer :amount
14
+ t.timestamps
15
+ end
16
+ create_table(:users)
17
+ end
18
+
19
+ def fill_database
20
+ 10.times do
21
+ user = User.create!
22
+ 10.times do
23
+ Payment.create!(user: user, amount: rand(1000))
24
+ end
25
+ end
26
+ end
data/examples/core.rb ADDED
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "n1_loader"
4
+
5
+ require_relative 'context/service'
6
+
7
+ # Class that wants to request 3rd party service without N+1
8
+ class User
9
+ include N1Loader::Loadable
10
+
11
+ def unoptimized_call
12
+ Service.receive(self)[0]
13
+ end
14
+
15
+ n1_optimized :optimized_call do |users|
16
+ data = Service.receive(users)
17
+
18
+ users.each_with_index do |user, index|
19
+ fulfill(user, data[index])
20
+ end
21
+ end
22
+ end
23
+
24
+ # works fine for single case
25
+ user = User.new
26
+ p "Works correctly: #{user.unoptimized_call == user.optimized_call}"
27
+
28
+ users = [User.new, User.new]
29
+
30
+ # Has N+1
31
+ count_before = Service.count
32
+ p users.map(&:unoptimized_call)
33
+ p "Has N+1 #{Service.count == count_before + users.count}"
34
+
35
+ # Has no N+1
36
+ count_before = Service.count
37
+ N1Loader::Preloader.new(users).preload(:optimized_call)
38
+ p users.map(&:optimized_call)
39
+ p "Has no N+1: #{Service.count == count_before + 1}"
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "n1_loader/ar_lazy_preload"
4
+ require 'graphql'
5
+
6
+ require_relative 'context/setup_database'
7
+ require_relative 'context/setup_ar_lazy'
8
+
9
+ class User < ActiveRecord::Base
10
+ has_many :payments
11
+
12
+ n1_optimized :payments_total do |users|
13
+ total_per_user = Payment.group(:user_id).where(user: users).sum(:amount).tap { |h| h.default = 0 }
14
+
15
+ users.each do |user|
16
+ total = total_per_user[user.id]
17
+ fulfill(user, total)
18
+ end
19
+ end
20
+ end
21
+
22
+ class Payment < ActiveRecord::Base
23
+ belongs_to :user
24
+
25
+ validates :amount, presence: true
26
+ end
27
+
28
+ 10.times do
29
+ user = User.create!
30
+ 10.times do
31
+ Payment.create!(user: user, amount: rand(1000))
32
+ end
33
+ end
34
+
35
+ ArLazyPreload.config.auto_preload = true
36
+ # Or use +preload_associations_lazily+ when loading objects from database
37
+
38
+ class UserType < GraphQL::Schema::Object
39
+ field :payments_total, Integer
40
+ end
41
+
42
+ class QueryType < GraphQL::Schema::Object
43
+ field :users, [UserType]
44
+
45
+ def users
46
+ User.all
47
+ end
48
+ end
49
+
50
+ class Schema < GraphQL::Schema
51
+ query QueryType
52
+ end
53
+
54
+ query_string = <<~GQL
55
+ {
56
+ users {
57
+ paymentsTotal
58
+ }
59
+ }
60
+ GQL
61
+
62
+ # No N+1. And never will be!
63
+ p Schema.execute(query_string)['data']
@@ -0,0 +1,13 @@
1
+ require 'n1_loader'
2
+
3
+ class IsolatedLoader < N1Loader::Loader
4
+ def perform(elements)
5
+ elements.each { |element| fulfill(element, [element]) }
6
+ end
7
+ end
8
+
9
+ objects = [1, 2, 3, 4]
10
+ loader = IsolatedLoader.new(objects)
11
+ objects.each do |object|
12
+ loader.for(object) # => it has no N+1 and it doesn't require to be injected in the class
13
+ end
@@ -0,0 +1,26 @@
1
+ require 'n1_loader'
2
+
3
+ require_relative 'context/service'
4
+
5
+ # Class that wants to request 3rd party service without N+1
6
+ class User
7
+ include N1Loader::Loadable
8
+
9
+ n1_optimized :optimized_call do |users|
10
+ data = Service.receive(users)
11
+
12
+ users.each_with_index do |user, index|
13
+ fulfill(user, data[index])
14
+ end
15
+ end
16
+ end
17
+
18
+ users = [User.new, User.new, User.new]
19
+
20
+ # Initialized loader but didn't perform it yet
21
+ N1Loader::Preloader.new(users).preload(:optimized_call)
22
+ p "No calls yet: #{Service.count == 0}"
23
+
24
+ # First time loading
25
+ users.map(&:optimized_call)
26
+ p "First time loaded: #{Service.count == 1}"
@@ -0,0 +1,32 @@
1
+ require 'n1_loader'
2
+
3
+ require_relative 'context/service'
4
+
5
+ class User
6
+ include N1Loader::Loadable
7
+
8
+ n1_optimized :optimized_call do |users|
9
+ data = Service.receive(users)
10
+
11
+ users.each_with_index do |user, index|
12
+ fulfill(user, data[index])
13
+ end
14
+ end
15
+ end
16
+
17
+ users = [User.new, User.new, User.new]
18
+
19
+ # Initialized loader but didn't perform it yet
20
+ N1Loader::Preloader.new(users).preload(:optimized_call)
21
+ p "No calls yet: #{Service.count == 0}"
22
+
23
+ # First time loading
24
+ users.map(&:optimized_call)
25
+ p "First time loaded: #{Service.count == 1}"
26
+
27
+ users.first.optimized_call(reload: true)
28
+ p "Reloaded for this object only: #{Service.count == 2}"
29
+
30
+ users.first.n1_clear_cache
31
+ users.first.optimized_call
32
+ p "Reloaded for this object only: #{Service.count == 3}"
@@ -0,0 +1,34 @@
1
+ require 'n1_loader'
2
+
3
+ require_relative 'context/service'
4
+
5
+ # Loader that will be shared between multiple classes
6
+ class SharedLoader < N1Loader::Loader
7
+ def perform(objects)
8
+ data = Service.receive(objects)
9
+
10
+ objects.each_with_index do |user, index|
11
+ fulfill(user, data[index])
12
+ end
13
+ end
14
+ end
15
+
16
+ class User
17
+ include N1Loader::Loadable
18
+
19
+ n1_optimized :optimized_call, SharedLoader
20
+ end
21
+
22
+ class Payment
23
+ include N1Loader::Loadable
24
+
25
+ n1_optimized :optimized_call, SharedLoader
26
+ end
27
+
28
+ objects = [User.new, Payment.new, User.new, Payment.new]
29
+
30
+ N1Loader::Preloader.new(objects).preload(:optimized_call)
31
+
32
+ # First time loading for all objects
33
+ objects.map(&:optimized_call)
34
+ p "Loaded for all once: #{Service.count == 1}"
@@ -0,0 +1,34 @@
1
+ require 'n1_loader'
2
+
3
+ require_relative 'context/service'
4
+
5
+ # Loader that will be shared between multiple classes
6
+ class OptimizedLoader < N1Loader::Loader
7
+ def perform(objects)
8
+ data = Service.receive(objects)
9
+
10
+ objects.each_with_index do |user, index|
11
+ fulfill(user, data[index])
12
+ end
13
+ end
14
+
15
+ def single(object)
16
+ Service.single(object)
17
+ end
18
+ end
19
+
20
+ class User
21
+ include N1Loader::Loadable
22
+
23
+ n1_optimized :optimized_call, OptimizedLoader
24
+ end
25
+
26
+ objects = [User.new, User.new]
27
+
28
+ N1Loader::Preloader.new(objects).preload(:optimized_call)
29
+
30
+ objects.map(&:optimized_call)
31
+ p "Used multi-case perform: #{Service.count == 1}"
32
+
33
+ User.new.optimized_call
34
+ p "Used single-case perform: #{Service.count == 1}"
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module N1Loader
4
+ module ActiveRecord
5
+ module Associations
6
+ module Preloader # :nodoc:
7
+ N1LoaderReflection = Struct.new(:key, :loader) do
8
+ def options
9
+ {}
10
+ end
11
+ end
12
+
13
+ def preloaders_for_reflection(reflection, records)
14
+ return super unless reflection.is_a?(N1LoaderReflection)
15
+
16
+ N1Loader::Preloader.new(records).preload(reflection.key)
17
+ end
18
+
19
+ def grouped_records # rubocop:disable Metrics/PerceivedComplexity, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
20
+ n1_load_records, records = source_records.partition do |record|
21
+ record.class.respond_to?(:n1_loader_defined?) && record.class.n1_loader_defined?(association)
22
+ end
23
+
24
+ h = n1_load_records.group_by do |record|
25
+ N1LoaderReflection.new(association, record.class.n1_loader(association))
26
+ end
27
+
28
+ polymorphic_parent = !root? && parent.polymorphic?
29
+ records.each do |record|
30
+ reflection = record.class._reflect_on_association(association)
31
+ next if polymorphic_parent && !reflection || !record.association(association).klass
32
+
33
+ (h[reflection] ||= []) << record
34
+ end
35
+ h
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module N1Loader
4
+ module ActiveRecord
5
+ # Extension module for ActiveRecord::Base
6
+ module Base
7
+ extend ActiveSupport::Concern
8
+
9
+ include N1Loader::Loadable
10
+
11
+ # Clear N1Loader cache on reloading the object
12
+ def reload(*)
13
+ n1_clear_cache
14
+ super
15
+ end
16
+ end
17
+ end
18
+ end
@@ -5,3 +5,11 @@ N1Loader::LoaderCollection.define_method :preloaded_records do
5
5
 
6
6
  with.preloaded_records
7
7
  end
8
+
9
+ N1Loader::LoaderCollection.define_method :runnable_loaders do
10
+ [self]
11
+ end
12
+
13
+ N1Loader::LoaderCollection.define_method :run? do
14
+ true
15
+ end
@@ -16,13 +16,19 @@ end
16
16
  ActiveSupport.on_load(:active_record) do
17
17
  require_relative "active_record/loader"
18
18
  require_relative "active_record/loader_collection"
19
+ require_relative "active_record/base"
19
20
 
20
21
  case ActiveRecord::VERSION::MAJOR
21
22
  when 6
22
23
  require_relative "active_record/associations_preloader_v6"
23
- else
24
+ ActiveRecord::Associations::Preloader.prepend(N1Loader::ActiveRecord::Associations::Preloader)
25
+ when 5
24
26
  require_relative "active_record/associations_preloader_v5"
27
+ ActiveRecord::Associations::Preloader.prepend(N1Loader::ActiveRecord::Associations::Preloader)
28
+ else
29
+ require_relative "active_record/associations_preloader_v7"
30
+ ActiveRecord::Associations::Preloader::Branch.prepend(N1Loader::ActiveRecord::Associations::Preloader)
25
31
  end
26
32
 
27
- ActiveRecord::Associations::Preloader.prepend(N1Loader::ActiveRecord::Associations::Preloader)
33
+ ActiveRecord::Base.include(N1Loader::ActiveRecord::Base)
28
34
  end
@@ -17,10 +17,13 @@ module N1Loader
17
17
  # @param name [Symbol]
18
18
  # @param opts [Hash]
19
19
  # @option opts [Boolean] optional false by default
20
+ # @option opts [Proc] default
20
21
  def argument(name, **opts)
22
+ opts[:optional] = true if opts[:default]
23
+
21
24
  @arguments ||= []
22
25
 
23
- define_method(name) { args[name] }
26
+ define_method(name) { args[name] ||= opts[:default]&.call }
24
27
 
25
28
  @arguments << opts.merge(name: name)
26
29
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module N1Loader
4
- VERSION = "1.4.2"
4
+ VERSION = "1.5.0"
5
5
  end
data/n1_loader.gemspec CHANGED
@@ -24,7 +24,8 @@ Gem::Specification.new do |spec|
24
24
 
25
25
  spec.add_development_dependency "activerecord", ">= 5"
26
26
  spec.add_development_dependency "ar_lazy_preload", ">= 0.6"
27
- spec.add_development_dependency "db-query-matchers", "~> 0.10"
27
+ spec.add_development_dependency "db-query-matchers", "~> 0.11"
28
+ spec.add_development_dependency "graphql", "~> 2.0"
28
29
  spec.add_development_dependency "rails", ">= 5"
29
30
  spec.add_development_dependency "rspec", "~> 3.0"
30
31
  spec.add_development_dependency "rspec_junit_formatter", "~> 0.4"
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.2
4
+ version: 1.5.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-03-01 00:00:00.000000000 Z
11
+ date: 2022-05-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -44,14 +44,28 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '0.10'
47
+ version: '0.11'
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '0.10'
54
+ version: '0.11'
55
+ - !ruby/object:Gem::Dependency
56
+ name: graphql
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '2.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '2.0'
55
69
  - !ruby/object:Gem::Dependency
56
70
  name: rails
57
71
  requirement: !ruby/object:Gem::Requirement
@@ -141,14 +155,30 @@ files:
141
155
  - Rakefile
142
156
  - activerecord-gemfiles/ar_5_latest.gemfile
143
157
  - activerecord-gemfiles/ar_6_latest.gemfile
158
+ - activerecord-gemfiles/ar_7_latest.gemfile
144
159
  - ar_lazy_preload-gemfiles/ar_lazy_preload_0.6.1.gemfile
145
160
  - ar_lazy_preload-gemfiles/ar_lazy_preload_master.gemfile
146
161
  - bin/console
147
162
  - bin/setup
163
+ - examples/active_record_integration.rb
164
+ - examples/ar_lazy_integration.rb
165
+ - examples/arguments_support.rb
166
+ - examples/context/service.rb
167
+ - examples/context/setup_ar_lazy.rb
168
+ - examples/context/setup_database.rb
169
+ - examples/core.rb
170
+ - examples/graphql.rb
171
+ - examples/isolated_loader.rb
172
+ - examples/lazy_loading.rb
173
+ - examples/reloading.rb
174
+ - examples/shared_loader.rb
175
+ - examples/single_case.rb
148
176
  - lib/n1_loader.rb
149
177
  - lib/n1_loader/active_record.rb
150
178
  - lib/n1_loader/active_record/associations_preloader_v5.rb
151
179
  - lib/n1_loader/active_record/associations_preloader_v6.rb
180
+ - lib/n1_loader/active_record/associations_preloader_v7.rb
181
+ - lib/n1_loader/active_record/base.rb
152
182
  - lib/n1_loader/active_record/loader.rb
153
183
  - lib/n1_loader/active_record/loader_collection.rb
154
184
  - lib/n1_loader/ar_lazy_preload.rb
@@ -186,7 +216,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
186
216
  - !ruby/object:Gem::Version
187
217
  version: '0'
188
218
  requirements: []
189
- rubygems_version: 3.2.22
219
+ rubygems_version: 3.1.6
190
220
  signing_key:
191
221
  specification_version: 4
192
222
  summary: Loader to solve N+1 issue for good.