n1_loader 1.4.1 → 1.4.4

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: fc06934b6c230d9b48dce18c7e9ee49afad4ab32d51d4550eb3e2916fe977656
4
- data.tar.gz: 456575999736bba2b4b8f33ff02a4908a7705b2cc31875bca9c80f3dbeeb4125
3
+ metadata.gz: 3e0025899b1fbd0329159dcd2e1ddf1d28318fe76354fd2e1378b803e49a9d96
4
+ data.tar.gz: 2f0f222cd9298bb56e2c3a9ae8c1f6559c7ec2bec3653c5a9a2c7c172b49b7fd
5
5
  SHA512:
6
- metadata.gz: 4640cffcc0fe6e03672ba46fc16e9fc29fc33d1c18b6d9e54e4c6026cd0d0b465846a60e0b75ab0dec46fd4db21ae914a0f92d3cc4623e622b4782094d6ce680
7
- data.tar.gz: a010ffac1ce7604ce9434de734fa20f0e5162ff7307f6ddcafe0b9be1119a4f37d3774b7dd4beb8d36affd56e4f30830d01904406ce1f2b2fc2989d7f9910fa1
6
+ metadata.gz: 48a55dd01386410a58a5fbf8ea9d556ac6fa2db33411a469b2c9f3fc99b9ccec2ad36eb482e0adba39a84af4ffd4adba6a51a8db23eba7869c03aca51add1d39
7
+ data.tar.gz: b4c275204251a8e6908e34af67beacd6bbc05a1fcd1168350ab538ecf48362c840c671d1223dfd3659b2fe87f1428ee0e012144c3c52fa11451a595a0b5a11d9
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.4.4] - 2022/04/29
2
+
3
+ - Inject `N1Loader::Loadable` to `ActiveRecord::Base` automatically
4
+ - Make `reload` to call `n1_clear_cache`
5
+
6
+ ## [1.4.3] - 2022-04-13
7
+
8
+ - Add `default` support to arguments
9
+
10
+ ## [1.4.2] - 2022-03-01
11
+
12
+ - Add n1_clear_cache method which is useful for cases like reload in ActiveRecord
13
+
1
14
  ## [1.4.1] - 2022-02-24
2
15
 
3
16
  - Fix preloading of invalid objects
data/README.md CHANGED
@@ -3,295 +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 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
42
-
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
24
+ ## Enhance [ActiveRecord][5]
54
25
 
55
- # For single object
56
- user = User.new
57
- user.orders_count
26
+ Are you working with well-known Rails application? Try it out how well N1Loader fulfills missing gaps!
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
46
+ ## How to use it?
132
47
 
133
- users = [User.new, User.new]
134
- N1Loader::Preloader.new(users).preload(:orders_count) # => loader was initialized but not yet executed
135
- users.map(&:orders_count) # => loader was executed first time without N+1 issue and values were cached
136
-
137
- N1Loader::Preloader.new(users).preload(:orders_count) # => loader was initialized again but not yet executed
138
- users.map(&:orders_count) # => new loader was executed first time without N+1 issue and new values were cached
139
- ```
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.
140
50
 
141
- ### 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).
142
53
 
54
+ Let's look at simple example below ([full snippet](examples/active_record_integration.rb)):
143
55
  ```ruby
144
- class IsolatedLoader < N1Loader::Loader
145
- def perform(elements)
146
- 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
147
70
  end
148
71
  end
149
72
 
150
- objects = [1, 2, 3, 4]
151
- loader = IsolatedLoader.new(objects)
152
- objects.each do |object|
153
- 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
154
77
  end
155
- ```
156
78
 
157
- ### 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:
158
81
 
159
- ```ruby
160
- class User
161
- include N1Loader::Loadable
82
+ # Has N+1 issue
83
+ p User.all.map { |user| user.payments.sum(&:amount) }
162
84
 
163
- n1_optimized :orders_count do # no arguments passed to the block, so we can override both perform and single.
164
- def perform(users)
165
- orders_per_user = Order.where(user: users).group(:user_id).count
166
-
167
- users.each { |user| fulfill(user, orders_per_user[user.id]) }
168
- end
169
-
170
- # Optimized for single object loading
171
- def single(user)
172
- user.orders.count
173
- end
174
- end
175
- end
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) }
176
87
 
177
- user = User.new
178
- user.orders_count # single will be used here
179
-
180
- users = [User.new, User.new]
181
- N1Loader::Preloader.new(users).preload(:orders_count)
182
- 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 }
183
90
  ```
184
91
 
185
- ### 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))
186
94
 
187
95
  ```ruby
188
- class User
189
- include N1Loader::Loadable
190
-
191
- n1_optimized :orders_count do
192
- argument :type
193
-
194
- def perform(users)
195
- orders_per_user = Order.where(type: type, user: users).group(:user_id).count
196
-
197
- users.each { |user| fulfill(user, orders_per_user[user.id]) }
198
- end
199
- end
200
- end
201
-
202
- user = User.new
203
- user.orders_count(type: :gifts) # The loader will be performed first time for this argument
204
- user.orders_count(type: :sales) # The loader will be performed first time for this argument
205
- user.orders_count(type: :gifts) # The cached value will be used
206
-
207
- users = [User.new, User.new]
208
- N1Loader::Preloader.new(users).preload(:orders_count)
209
- users.map { |user| user.orders_count(type: :gifts) } # No N+1 here
210
- ```
96
+ class User < ActiveRecord::Base
97
+ has_many :payments
211
98
 
212
- _Note_: By default, we use `arguments.map(&:object_id)` to identify arguments but in some cases,
213
- you may want to override it, for example:
99
+ n1_optimized :payments_total do
100
+ argument :from
101
+ argument :to
214
102
 
215
- ```ruby
216
- class User
217
- include N1Loader::Loadable
218
-
219
- n1_optimized :orders_count do
220
- argument :sale
221
-
222
- cache_key { sale.id }
223
-
224
103
  def perform(users)
225
- orders_per_user = Order.where(sale: sale, user: users).group(:user_id).count
226
-
227
- 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
228
116
  end
229
117
  end
230
118
  end
231
119
 
232
- user = User.new
233
- user.orders_count(sale: Sale.first) # perform will be executed and value will be cached
234
- user.orders_count(sale: Sale.first) # the cached value will be returned
235
- ```
236
-
237
-
238
- ## Integrations
239
-
240
- ### [ActiveRecord][5]
241
-
242
- _Note_: Rails 7 support is coming soon! Stay tuned!
120
+ class Payment < ActiveRecord::Base
121
+ belongs_to :user
243
122
 
244
- ```ruby
245
- class User < ActiveRecord::Base
246
- include N1Loader::Loadable
247
-
248
- n1_optimized :orders_count do |users|
249
- orders_per_user = Order.where(user: users).group(:user_id).count
250
-
251
- users.each { |user| fulfill(user, orders_per_user[user.id]) }
252
- end
123
+ validates :amount, presence: true
253
124
  end
254
125
 
255
- # For single user
256
- user = User.first
257
- user.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) }
258
128
 
259
- # For many users without N+1
260
- User.limit(5).includes(:orders_count).map(&: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) }
261
131
 
262
- # or with explicit preloader
263
- users = User.limit(5).to_a
264
- N1Loader::Preloader.new(users).preload(:orders_count)
265
-
266
- # No N+1 here
267
- 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) }
268
134
  ```
269
135
 
270
- ### [ArLazyPreload][6]
271
-
272
- ```ruby
273
- class User < ActiveRecord::Base
274
- include N1Loader::Loadable
275
-
276
- n1_optimized :orders_count do |users|
277
- orders_per_user = Order.where(user: users).group(:user_id).count
278
-
279
- users.each { |user| fulfill(user, orders_per_user[user.id]) }
280
- end
281
- end
282
-
283
- # For single user
284
- user = User.first
285
- user.orders_count
286
-
287
- # For many users without N+1
288
- User.lazy_preload(:orders_count).all.map(&:orders_count)
289
- # or
290
- User.preload_associations_lazily.all.map(&:orders_count)
291
- # or
292
- ArLazyPreload.config.auto_preload = true
293
- User.all.map(:orders_count)
294
- ```
136
+ ## Features and benefits
137
+
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
295
147
 
296
148
  ## Contributing
297
149
 
@@ -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,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
@@ -16,6 +16,7 @@ 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
@@ -25,4 +26,5 @@ ActiveSupport.on_load(:active_record) do
25
26
  end
26
27
 
27
28
  ActiveRecord::Associations::Preloader.prepend(N1Loader::ActiveRecord::Associations::Preloader)
29
+ ActiveRecord::Base.include(N1Loader::ActiveRecord::Base)
28
30
  end
@@ -32,6 +32,12 @@ module N1Loader
32
32
  send("#{name}_loader=", loader_collection)
33
33
  end
34
34
 
35
+ def n1_clear_cache
36
+ self.class.n1_loaders.each do |name|
37
+ n1_loader_set(name, nil)
38
+ end
39
+ end
40
+
35
41
  def self.included(base)
36
42
  base.extend(ClassMethods)
37
43
  end
@@ -45,6 +51,10 @@ module N1Loader
45
51
  respond_to?("#{name}_loader")
46
52
  end
47
53
 
54
+ def n1_loaders
55
+ @n1_loaders ||= superclass.respond_to?(:n1_loaders) ? superclass.n1_loaders.dup : []
56
+ end
57
+
48
58
  def n1_optimized(name, loader = nil, &block) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
49
59
  loader ||= Class.new(N1Loader::Loader) do
50
60
  if block.arity == 1
@@ -56,6 +66,8 @@ module N1Loader
56
66
  loader_name = "#{name}_loader"
57
67
  loader_variable_name = "@#{loader_name}"
58
68
 
69
+ n1_loaders << name
70
+
59
71
  define_singleton_method(loader_name) do
60
72
  loader
61
73
  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.1"
4
+ VERSION = "1.4.4"
5
5
  end
data/n1_loader.gemspec CHANGED
@@ -22,9 +22,10 @@ Gem::Specification.new do |spec|
22
22
  end
23
23
  spec.require_paths = ["lib"]
24
24
 
25
- spec.add_development_dependency "activerecord", ">= 5"
25
+ spec.add_development_dependency "activerecord", ">= 5", "< 7"
26
26
  spec.add_development_dependency "ar_lazy_preload", ">= 0.6"
27
27
  spec.add_development_dependency "db-query-matchers", "~> 0.10"
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.1
4
+ version: 1.4.4
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-02-24 00:00:00.000000000 Z
11
+ date: 2022-04-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -17,6 +17,9 @@ dependencies:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '5'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '7'
20
23
  type: :development
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
@@ -24,6 +27,9 @@ dependencies:
24
27
  - - ">="
25
28
  - !ruby/object:Gem::Version
26
29
  version: '5'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '7'
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: ar_lazy_preload
29
35
  requirement: !ruby/object:Gem::Requirement
@@ -52,6 +58,20 @@ dependencies:
52
58
  - - "~>"
53
59
  - !ruby/object:Gem::Version
54
60
  version: '0.10'
61
+ - !ruby/object:Gem::Dependency
62
+ name: graphql
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.0'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '2.0'
55
75
  - !ruby/object:Gem::Dependency
56
76
  name: rails
57
77
  requirement: !ruby/object:Gem::Requirement
@@ -145,10 +165,24 @@ files:
145
165
  - ar_lazy_preload-gemfiles/ar_lazy_preload_master.gemfile
146
166
  - bin/console
147
167
  - bin/setup
168
+ - examples/active_record_integration.rb
169
+ - examples/ar_lazy_integration.rb
170
+ - examples/arguments_support.rb
171
+ - examples/context/service.rb
172
+ - examples/context/setup_ar_lazy.rb
173
+ - examples/context/setup_database.rb
174
+ - examples/core.rb
175
+ - examples/graphql.rb
176
+ - examples/isolated_loader.rb
177
+ - examples/lazy_loading.rb
178
+ - examples/reloading.rb
179
+ - examples/shared_loader.rb
180
+ - examples/single_case.rb
148
181
  - lib/n1_loader.rb
149
182
  - lib/n1_loader/active_record.rb
150
183
  - lib/n1_loader/active_record/associations_preloader_v5.rb
151
184
  - lib/n1_loader/active_record/associations_preloader_v6.rb
185
+ - lib/n1_loader/active_record/base.rb
152
186
  - lib/n1_loader/active_record/loader.rb
153
187
  - lib/n1_loader/active_record/loader_collection.rb
154
188
  - lib/n1_loader/ar_lazy_preload.rb
@@ -186,7 +220,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
186
220
  - !ruby/object:Gem::Version
187
221
  version: '0'
188
222
  requirements: []
189
- rubygems_version: 3.2.22
223
+ rubygems_version: 3.1.6
190
224
  signing_key:
191
225
  specification_version: 4
192
226
  summary: Loader to solve N+1 issue for good.