n1_loader 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.