n1_loader 1.7.2 → 1.7.3

Sign up to get free protection for your applications and to get access to all the features.
data/examples/graphql.rb DELETED
@@ -1,63 +0,0 @@
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']
@@ -1,13 +0,0 @@
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
@@ -1,26 +0,0 @@
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}"
@@ -1,32 +0,0 @@
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}"
@@ -1,34 +0,0 @@
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}"
@@ -1,34 +0,0 @@
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}"
@@ -1,266 +0,0 @@
1
- # Enhanced ActiveRecord
2
-
3
- - Do you like `ActiveRecord` preloading?
4
- - How many times have you resolved your N+1 issues with `includes` or `preload`?
5
- - Do you know that preloading has limitations?
6
-
7
- In this guide, I'd like to share with you tips and tricks about ActiveRecord
8
- preloading and how you can enhance it to the next level.
9
-
10
- Let's start by describing the models.
11
-
12
- ```ruby
13
- # The model represents users in our application.
14
- class User < ActiveRecord::Base
15
- # Every user may have from 0 to many payments.
16
- has_many :payments
17
- end
18
-
19
- # The model represents payments in our application.
20
- class Payment < ActiveRecord::Base
21
- # Every payment belongs to a user.
22
- belongs_to :user
23
- end
24
- ```
25
-
26
- Assuming we want to iterate over a group of users and check how many payments they have, we may do:
27
-
28
- ```ruby
29
- # The query we want to use to fetch users from the database.
30
- users = User.all
31
- # Iteration over selected users.
32
- users.each do |user|
33
- # Print amount of user's payments.
34
- # This query will be called for every user, bringing an N+1 issue.
35
- p user.payments.count
36
- end
37
- ```
38
-
39
- We can fix the N+1 issue above in a second.
40
- We need to add ActiveRecord's `includes` to the query that fetches users.
41
-
42
- ```ruby
43
- # The query to fetch users with preload payments for every selected user.
44
- users = User.includes(:payments).all
45
- ```
46
-
47
- Then, we can iterate over the group again without the N+1 issue.
48
-
49
- ```ruby
50
- users.each do |user|
51
- p user.payments.count
52
- end
53
- ```
54
-
55
- Experienced with ActiveRecord person may notice that the iteration above still will have an N+1 issue.
56
- The reason is the `.count` method and its behavior.
57
- This issue brings us to the first tip.
58
-
59
- ### Tip 1. `count` vs `size` vs `length`
60
-
61
- - `count` - always queries the database with `COUNT` query;
62
- - `size` - queries the database with `COUNT` only when there is no preloaded data, returns array length otherwise;
63
- - `length` - always returns array length, in case there is no data, load it first.
64
-
65
- _Note:_ be careful with `size` as ordering is critical.
66
-
67
- Meaning, for `user = User.first`
68
-
69
- ```ruby
70
- # Does `COUNT` query
71
- user.payments.size
72
- # Does `SELECT` query
73
- user.payments.each { |payment| }
74
- ```
75
-
76
- is different from
77
-
78
- ```ruby
79
- # Does `SELECT` query
80
- user.payments.each { |payment| }
81
- # No query
82
- user.payments.size
83
- ```
84
-
85
- You may notice that the above solution loads all payment information when the amount is only needed.
86
- There is a well-known solution for this case called [counter_cache](https://guides.rubyonrails.org/association_basics.html#options-for-belongs-to-counter-cache).
87
-
88
- To use that, you need to add `payments_count` field to `users` table and adjust `Payment` model.
89
-
90
- ```ruby
91
- # Migration to add `payments_count` to `users` table.
92
- class AddPaymentsCountToUsers < ActiveRecord::Migration
93
- def change
94
- add_column :users, :payments_count, :integer, default: 0, null: false
95
- end
96
- end
97
-
98
- # Change `belongs_to` to have `counter_cache` option.
99
- class Payment < ActiveRecord::Base
100
- belongs_to :user, counter_cache: true
101
- end
102
- ```
103
-
104
- _Note:_ avoid adding or removing payments from the database directly or through `insert_all`/`delete`/`delete_all` as
105
- `counter_cache` is using ActiveRecord callbacks to update the field's value.
106
-
107
- It's worth mentioning [counter_culture](https://github.com/magnusvk/counter_culture) alternative that has many features compared with the built-in `counter_cache`
108
-
109
- ## Associations with arguments
110
-
111
- Now, let's assume we want to fetch the number of payments in a time frame for every user in a group.
112
-
113
- ```ruby
114
- from = 1.months.ago
115
- to = Time.current
116
-
117
- # Query to fetch users.
118
- users = User.all
119
-
120
- users.each do |user|
121
- # Print the number of payments in a time frame for every user.
122
- # Database query will be triggered for every user, meaning it has an N+1 issue.
123
- p user.payments.where(created_at: from...to).count
124
- end
125
- ```
126
-
127
- ActiveRecord supports defining associations with arguments.
128
-
129
- ```ruby
130
- class User < ActiveRecord::Base
131
- has_many :payments, -> (from, to) { where(created_at: from...to) }
132
- end
133
- ```
134
-
135
- Unfortunately, such associations are not possible to preload with `includes`.
136
- Gladly, there is a solution with [N1Loader](https://github.com/djezzzl/n1_loader/).
137
-
138
- ```ruby
139
- # Install gem dependencies.
140
- require 'n1_loader/active_record'
141
-
142
- class User < ActiveRecord::Base
143
- n1_optimized :payments_count do
144
- argument :from
145
- argument :to
146
-
147
- def perform(users)
148
- # Fetch the payment number once for all users.
149
- payments = Payment.where(user: users).where(created_at: from...to).group(:user_id).count
150
-
151
- users.each do |user|
152
- # Assign preloaded data to every user.
153
- # Note: it doesn't use any promises.
154
- fulfill(user, payments[user.id])
155
- end
156
- end
157
- end
158
- end
159
-
160
- from = 1.month.ago
161
- to = Time.current
162
-
163
- # Preload `payments` N1Loader "association". Doesn't query the database yet.
164
- users = User.includes(:payments_count).all
165
-
166
- users.each do |user|
167
- # Queries the database once, meaning has no N+1 issues.
168
- p user.payments_count(from, to)
169
- end
170
- ```
171
-
172
- Let's look at another example. Assuming we want to fetch the last payment for every user.
173
- We can try to define scoped `has_one` association and use that.
174
-
175
- ```ruby
176
- class User < ActiveRecord::Base
177
- has_one :last_payment, -> { order(id: :desc) }, class_name: 'Payment'
178
- end
179
- ```
180
-
181
- We can see that preloading is working.
182
-
183
- ```ruby
184
- users = User.includes(:last_payment)
185
-
186
- users.each do |user|
187
- # No N+1. Last payment was returned.
188
- p user.last_payment
189
- end
190
- ```
191
-
192
- At first glance, we may think everything is alright. Unfortunately, it is not.
193
-
194
- ### Tip 2. Enforce `has_one` associations on the database level
195
-
196
- ActiveRecord, fetches all available payments for every user with provided order and then assigns only first payment to the association.
197
- First, such querying is inefficient as we load many redundant information.
198
- But most importantly, this association may lead to big issues. Other engineers may use it, for example,
199
- for `joins(:last_payment)`. Assuming that association has strict agreement on the database level that
200
- a user may have none or a single payment in the database. Apparently, it may not be the case, and some queries
201
- will return unexpected data.
202
-
203
- Described issues may be found with [DatabaseConsistency](https://github.com/djezzzl/database_consistency).
204
-
205
- Back to the task, we can solve it with [N1Loader](https://github.com/djezzzl/n1_loader) in the following way
206
-
207
- ```ruby
208
- require 'n1_loader/active_record'
209
-
210
- class User < ActiveRecord::Base
211
- n1_optimized :last_payment do |users|
212
- subquery = Payment.select('MAX(id)').where(user: users)
213
- payments = Payment.where(id: subquery).index_by(&:user_id)
214
-
215
- users.each do |user|
216
- fulfill(user, payments[user.id])
217
- end
218
- end
219
- end
220
-
221
- users = User.includes(:last_payment).all
222
-
223
- users.each do |user|
224
- # Queries the database once, meaning no N+1.
225
- p user.last_payment
226
- end
227
- ```
228
-
229
- Attentive reader could notice that in every described case, it was a requirement to explicitly list data that we want to preload for a group of users.
230
- Gladly, there is a simple solution! [ArLazyPreload](https://github.com/DmitryTsepelev/ar_lazy_preload) will make N+1 disappear just by enabling it.
231
- As soon as you need to load association for any record, it will load it once for all records that were fetched along this one.
232
- And it works with ActiveRecord and N1Loader perfectly!
233
-
234
- Let's look at the example.
235
-
236
- ```ruby
237
- # Require N1Loader with ArLazyPreload integration
238
- require 'n1_loader/ar_lazy_preload'
239
-
240
- # Enable ArLazyPreload globally, so you don't need to care about `includes` anymore
241
- ArLazyPreload.config.auto_preload = true
242
-
243
- class User < ActiveRecord::Base
244
- has_many :payments
245
-
246
- n1_optimized :last_payment do |users|
247
- subquery = Payment.select('MAX(id)').where(user: users)
248
- payments = Payment.where(id: subquery).index_by(&:user_id)
249
-
250
- users.each do |user|
251
- fulfill(user, payments[user.id])
252
- end
253
- end
254
- end
255
-
256
- # no need to specify `includes`
257
- users = User.all
258
-
259
- users.each do |user|
260
- p user.payments # no N+1
261
- p user.last_payment # no N+1
262
- end
263
- ```
264
-
265
- As you can see, there is no need to even remember about resolving N+1 when you have both [ArLazyPreload](https://github.com/DmitryTsepelev/ar_lazy_preload) and [N1Loader](https://github.com/djezzzl/n1_loader) in your pocket.
266
- It works great with GraphQL API too. Give it and try and share your feedback!
data/n1_loader.gemspec DELETED
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/n1_loader/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "n1_loader"
7
- spec.version = N1Loader::VERSION
8
- spec.authors = ["Evgeniy Demin"]
9
- spec.email = ["lawliet.djez@gmail.com"]
10
-
11
- spec.summary = "Loader to solve N+1 issue for good."
12
- spec.homepage = "https://github.com/djezzzl/n1_loader"
13
- spec.license = "MIT"
14
- spec.required_ruby_version = ">= 2.5.0"
15
-
16
- spec.metadata["homepage_uri"] = spec.homepage
17
- spec.metadata["source_code_uri"] = "https://github.com/djezzzl/n1_loader"
18
- spec.metadata["changelog_uri"] = "https://github.com/djezzzl/n1_loader/master/CHANGELOG.md"
19
- spec.metadata["funding_uri"] = "https://opencollective.com/n1_loader#support"
20
-
21
- spec.files = Dir.chdir(File.expand_path(__dir__)) do
22
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
23
- end
24
- spec.require_paths = ["lib"]
25
-
26
- spec.add_development_dependency "activerecord", ">= 5"
27
- spec.add_development_dependency "ar_lazy_preload", ">= 0.6"
28
- spec.add_development_dependency "db-query-matchers", "~> 0.11"
29
- spec.add_development_dependency "graphql", "~> 2.0"
30
- spec.add_development_dependency "rails", ">= 5"
31
- spec.add_development_dependency "rspec", "~> 3.0"
32
- spec.add_development_dependency "rspec_junit_formatter", "~> 0.4"
33
- spec.add_development_dependency "rubocop", "~> 1.7"
34
- spec.add_development_dependency "sqlite3", "~> 1.3"
35
- end