n1_loader 1.7.1 → 1.7.3

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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/lib/n1_loader/active_record/associations_preloader_v5.rb +2 -2
  3. data/lib/n1_loader/active_record/associations_preloader_v6.rb +2 -2
  4. data/lib/n1_loader/active_record/associations_preloader_v7.rb +2 -2
  5. data/lib/n1_loader/ar_lazy_preload/loadable.rb +5 -14
  6. data/lib/n1_loader/ar_lazy_preload.rb +1 -1
  7. data/lib/n1_loader/core/loadable.rb +16 -59
  8. data/lib/n1_loader/core/loader.rb +8 -5
  9. data/lib/n1_loader/core/loader_builder.rb +16 -0
  10. data/lib/n1_loader/core/preloader.rb +5 -8
  11. data/lib/n1_loader/version.rb +1 -1
  12. data/lib/n1_loader.rb +1 -1
  13. metadata +3 -37
  14. data/.github/workflows/rubocop.yml +0 -24
  15. data/.github/workflows/tests.yml +0 -66
  16. data/.gitignore +0 -12
  17. data/.rspec +0 -3
  18. data/.rubocop.yml +0 -20
  19. data/CHANGELOG.md +0 -129
  20. data/CODE_OF_CONDUCT.md +0 -84
  21. data/Gemfile +0 -15
  22. data/LICENSE.txt +0 -21
  23. data/README.md +0 -276
  24. data/Rakefile +0 -12
  25. data/activerecord-gemfiles/ar_5_latest.gemfile +0 -3
  26. data/activerecord-gemfiles/ar_6_latest.gemfile +0 -3
  27. data/activerecord-gemfiles/ar_7_latest.gemfile +0 -3
  28. data/ar_lazy_preload-gemfiles/ar_lazy_preload_0.6.1.gemfile +0 -3
  29. data/ar_lazy_preload-gemfiles/ar_lazy_preload_master.gemfile +0 -3
  30. data/bin/console +0 -15
  31. data/bin/setup +0 -8
  32. data/examples/active_record_integration.rb +0 -33
  33. data/examples/ar_lazy_integration.rb +0 -36
  34. data/examples/ar_lazy_integration_with_isolated_loader.rb +0 -39
  35. data/examples/arguments_support.rb +0 -67
  36. data/examples/context/service.rb +0 -20
  37. data/examples/context/setup_ar_lazy.rb +0 -15
  38. data/examples/context/setup_database.rb +0 -26
  39. data/examples/core.rb +0 -39
  40. data/examples/graphql.rb +0 -63
  41. data/examples/isolated_loader.rb +0 -13
  42. data/examples/lazy_loading.rb +0 -26
  43. data/examples/reloading.rb +0 -32
  44. data/examples/shared_loader.rb +0 -34
  45. data/examples/single_case.rb +0 -34
  46. data/guides/enhanced-activerecord.md +0 -266
  47. data/lib/n1_loader/core/name.rb +0 -14
  48. data/n1_loader.gemspec +0 -35
@@ -1,20 +0,0 @@
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
@@ -1,15 +0,0 @@
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
@@ -1,26 +0,0 @@
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 DELETED
@@ -1,39 +0,0 @@
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}"
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!
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module N1Loader
4
- # Add support of question mark names
5
- module Name
6
- def n1_loader_name(name)
7
- to_sym = name.is_a?(Symbol)
8
-
9
- converted = name.to_s.gsub("?", "_question_mark")
10
-
11
- to_sym ? converted.to_sym : converted
12
- end
13
- end
14
- end
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