n_plus_one_control 0.2.1 → 0.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
- SHA1:
3
- metadata.gz: 942639e351ee999dc4a113c237d9604878d296cc
4
- data.tar.gz: 76d9b2b2fd28b02925ce4d219e3b5c6ac316d1fb
2
+ SHA256:
3
+ metadata.gz: 394ec848692af46dd43c3a03a3315349c2aea19d174ff05ededa1ef825839ee0
4
+ data.tar.gz: 7e2a271fe8ae173b117d1fc514e33b6f08aa7c528e4ea476f5360591a28da6f8
5
5
  SHA512:
6
- metadata.gz: 980d3feab9ebd9d9b51da847526a83ae0ed2b36e50509494d14bfad75578086b4c2ca6a6da60d5f518a6a2deb87c80af848c792046804e96b62249404f09535c
7
- data.tar.gz: ef2d79c707712f2a578ba4ff513c1f03b6c4cf351b72381875188ac6a3730c1f73b3fd11ab7a0538f5d76f2d67a5b6fb4f57775218e1ac8b1a0877e6936bd0a9
6
+ metadata.gz: 1f411b0c1539f517e0c4036570e274fe62327817722065551fce09307e15402c31185566343ddb9fd388324a71c5c8d7730fc938767397f5bbba60f103ffdccb
7
+ data.tar.gz: 21a4445b6dd7efa9dad9bbfe2ccf8708ae813e2e60ea424b60f7b00cf58bef1971d58b1392893f34e817a3834792b461536890ed38619a20e0f9bd770ab7ddfd
@@ -0,0 +1,28 @@
1
+ ## master (unreleased)
2
+
3
+ ## 0.5.0 (2020-09-07)
4
+
5
+ - **Ruby 2.5+ is required**. ([@palkan][])
6
+
7
+ - Add support for multiple backtrace lines in verbose output. ([@palkan][])
8
+
9
+ Could be specified via `NPLUSONE_BACKTRACE` env var.
10
+
11
+ - Add `NPLUSONE_TRUNCATE` env var to truncate queries in verbose mode. ([@palkan][])
12
+
13
+ - Support passing default filter via `NPLUSONE_FILTER` env var. ([@palkan][])
14
+
15
+ - Add location tracing to SQLs in verbose mode. ([@palkan][])
16
+
17
+ ## 0.4.1 (2020-09-04)
18
+
19
+ - Enhance failure message by showing differences in table hits. ([@palkan][])
20
+
21
+ ## 0.4.0 (2020-07-20)
22
+
23
+ - Make scale factor available in tests via `#current_scale` method. ([@Earendil95][])
24
+
25
+ - Start keeping a changelog. ([@palkan][])
26
+
27
+ [@Earendil95]: https://github.com/Earendil95
28
+ [@palkan]: https://github.com/palkan
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2017 palkan
3
+ Copyright (c) 2017-2020 Vladimir Dementyev
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,4 +1,5 @@
1
- [![Gem Version](https://badge.fury.io/rb/n_plus_one_control.svg)](https://rubygems.org/gems/n_plus_one_control) [![Build Status](https://travis-ci.org/palkan/n_plus_one_control.svg?branch=master)](https://travis-ci.org/palkan/n_plus_one_control)
1
+ [![Gem Version](https://badge.fury.io/rb/n_plus_one_control.svg)](https://rubygems.org/gems/n_plus_one_control)
2
+ ![Build](https://github.com/palkan/n_plus_one_control/workflows/Build/badge.svg)
2
3
 
3
4
  # N + 1 Control
4
5
 
@@ -31,7 +32,7 @@ Add this line to your application's Gemfile:
31
32
 
32
33
  ```ruby
33
34
  group :test do
34
- gem 'n_plus_one_control'
35
+ gem "n_plus_one_control"
35
36
  end
36
37
  ```
37
38
 
@@ -47,8 +48,6 @@ First, add NPlusOneControl to your `spec_helper.rb`:
47
48
 
48
49
  ```ruby
49
50
  # spec_helper.rb
50
- ...
51
-
52
51
  require "n_plus_one_control/rspec"
53
52
  ```
54
53
 
@@ -86,10 +85,30 @@ Availables modifiers:
86
85
  ```ruby
87
86
  # You can specify the RegExp to filter queries.
88
87
  # By default, it only considers SELECT queries.
89
- expect { ... }.to perform_constant_number_of_queries.matching(/INSERT/)
88
+ expect { subject }.to perform_constant_number_of_queries.matching(/INSERT/)
90
89
 
91
90
  # You can also provide custom scale factors
92
- expect { ... }.to perform_constant_number_of_queries.with_scale_factors(10, 100)
91
+ expect { subject }.to perform_constant_number_of_queries.with_scale_factors(10, 100)
92
+ ```
93
+
94
+ #### Using scale factor in spec
95
+
96
+ Let's suppose your action accepts parameter, which can make impact on the number of returned records:
97
+
98
+ ```ruby
99
+ get :index, params: {per_page: 10}
100
+ ```
101
+
102
+ Then it is enough to just change `per_page` parameter between executions and do not recreate records in DB. For this purpose, you can use `current_scale` method in your example:
103
+
104
+ ```ruby
105
+ context "N+1", :n_plus_one do
106
+ before { create_list :post, 3 }
107
+
108
+ specify do
109
+ expect { get :index, params: {per_page: current_scale} }.to perform_constant_number_of_queries
110
+ end
111
+ end
93
112
  ```
94
113
 
95
114
  ### Minitest
@@ -98,8 +117,6 @@ First, add NPlusOneControl to your `test_helper.rb`:
98
117
 
99
118
  ```ruby
100
119
  # test_helper.rb
101
- ...
102
-
103
120
  require "n_plus_one_control/minitest"
104
121
  ```
105
122
 
@@ -133,6 +150,12 @@ assert_perform_constant_number_of_queries(
133
150
  end
134
151
  ```
135
152
 
153
+ It's possible to specify a filter via `NPLUSONE_FILTER` env var, e.g.:
154
+
155
+ ```ruby
156
+ NPLUSONE_FILTER = users bundle exec rake test
157
+ ```
158
+
136
159
  You can also specify `populate` as a test class instance method:
137
160
 
138
161
  ```ruby
@@ -145,6 +168,68 @@ def test_no_n_plus_one_error
145
168
  get :index
146
169
  end
147
170
  end
171
+
172
+ ```
173
+
174
+ As in RSpec, you can use `current_scale` factor instead of `populate` block:
175
+
176
+ ```ruby
177
+ def test_no_n_plus_one_error
178
+ assert_perform_constant_number_of_queries do
179
+ get :index, params: {per_page: current_scale}
180
+ end
181
+ end
182
+ ```
183
+
184
+ ### With caching
185
+
186
+ If you use caching you can face the problem when first request performs more DB queries than others. The solution is:
187
+
188
+ ```ruby
189
+ # RSpec
190
+
191
+ context "N + 1", :n_plus_one do
192
+ populate { |n| create_list :post, n }
193
+
194
+ warmup { get :index } # cache something must be cached
195
+
196
+ specify do
197
+ expect { get :index }.to perform_constant_number_of_queries
198
+ end
199
+ end
200
+
201
+ # Minitest
202
+
203
+ def populate(n)
204
+ create_list(:post, n)
205
+ end
206
+
207
+ def warmup
208
+ get :index
209
+ end
210
+
211
+ def test_no_n_plus_one_error
212
+ assert_perform_constant_number_of_queries do
213
+ get :index
214
+ end
215
+ end
216
+
217
+ # or with params
218
+
219
+ def test_no_n_plus_one
220
+ populate = ->(n) { create_list(:post, n) }
221
+ warmup = -> { get :index }
222
+
223
+ assert_perform_constant_number_of_queries population: populate, warmup: warmup do
224
+ get :index
225
+ end
226
+ end
227
+ ```
228
+
229
+ If your `warmup` and testing procs are identical, you can use:
230
+
231
+ ```ruby
232
+ expext { get :index }.to perform_constant_number_of_queries.with_warming_up # RSpec only
148
233
  ```
149
234
 
150
235
  ### Configuration
@@ -160,13 +245,21 @@ NPlusOneControl.default_scale_factors = [2, 3]
160
245
  # You can activate verbosity through env variable NPLUSONE_VERBOSE=1
161
246
  NPlusOneControl.verbose = false
162
247
 
248
+ # Print table hits difference, for example:
249
+ #
250
+ # Unmatched query numbers by tables:
251
+ # users (SELECT): 2 != 3
252
+ # events (INSERT): 1 != 2
253
+ #
254
+ self.show_table_stats = true
255
+
163
256
  # Ignore matching queries
164
257
  NPlusOneControl.ignore = /^(BEGIN|COMMIT|SAVEPOINT|RELEASE)/
165
258
 
166
259
  # ActiveSupport notifications event to track queries.
167
260
  # We track ActiveRecord event by default,
168
261
  # but can also track rom-rb events ('sql.rom') as well.
169
- NPlusOneControl.event = 'sql.active_record'
262
+ NPlusOneControl.event = "sql.active_record"
170
263
 
171
264
  # configure transactional behavour for populate method
172
265
  # in case of use multiple database connections
@@ -180,6 +273,21 @@ NPlusOneControl::Executor.tap do |executor|
180
273
  connections.each(&:rollback_transaction)
181
274
  end
182
275
  end
276
+
277
+ # Provide a backtrace cleaner callable object used to filter SQL caller location to display in the verbose mode
278
+ # Set it to nil to disable tracing.
279
+ #
280
+ # In Rails apps, we use Rails.backtrace_cleaner by default.
281
+ NPlusOneControl.backtrace_cleaner = ->(locations_array) { do_some_filtering(locations_array) }
282
+
283
+ # You can also specify the number of backtrace lines to show.
284
+ # MOTE: It could be specified via NPLUSONE_BACKTRACE env var
285
+ NPlusOneControl.backtrace_length = 1
286
+
287
+ # Sometime queries could be too large to provide any meaningful insight.
288
+ # You can configure an output length limit for quries in verbose mode by setting the follwing option
289
+ # NOTE: It could be specified via NPLUSONE_TRUNCATE env var
290
+ NPlusOneControl.truncate_query_size = 100
183
291
  ```
184
292
 
185
293
  ## How does it work?
@@ -188,22 +296,29 @@ Take a look at our [Executor](https://github.com/palkan/n_plus_one_control/blob/
188
296
 
189
297
  ## What's next?
190
298
 
299
+ - More matchers.
300
+
191
301
  It may be useful to provide more matchers/assertions, for example:
192
302
 
193
303
  ```ruby
194
304
 
195
305
  # Actually, that means that it is N+1))
196
- assert_linear_number_of_queries { ... }
306
+ assert_linear_number_of_queries { some_code }
197
307
 
198
308
  # But we can tune it with `coef` and handle such cases as selecting in batches
199
309
  assert_linear_number_of_queries(coef: 0.1) do
200
- Post.find_in_batches { ... }
310
+ Post.find_in_batches { some_code }
201
311
  end
202
312
 
203
313
  # probably, also make sense to add another curve types
204
- assert_logarithmic_number_of_queries { ... }
314
+ assert_logarithmic_number_of_queries { some_code }
205
315
  ```
206
316
 
317
+ - Support custom non-SQL events.
318
+
319
+ N+1 problem is not a database specific: we can have N+1 Redis calls, N+1 HTTP external requests, etc.
320
+ We can make `n_plus_one_control` customizable to support these scenarios (technically, we need to make it possible to handle different payload in the event subscriber).
321
+
207
322
  If you want to discuss or implement any of these, feel free to open an [issue](https://github.com/palkan/n_plus_one_control/issues) or propose a [pull request](https://github.com/palkan/n_plus_one_control/pulls).
208
323
 
209
324
  ## Development
@@ -5,17 +5,94 @@ require "n_plus_one_control/executor"
5
5
 
6
6
  # RSpec and Minitest matchers to prevent N+1 queries problem.
7
7
  module NPlusOneControl
8
+ # Used to extract a table name from a query
9
+ EXTRACT_TABLE_RXP = /(insert into|update|delete from|from) ['"](\S+)['"]/i.freeze
10
+
11
+ # Used to convert a query part extracted by the regexp above to the corresponding
12
+ # human-friendly type
13
+ QUERY_PART_TO_TYPE = {
14
+ "insert into" => "INSERT",
15
+ "update" => "UPDATE",
16
+ "delete from" => "DELETE",
17
+ "from" => "SELECT"
18
+ }.freeze
19
+
8
20
  class << self
9
- attr_accessor :default_scale_factors, :verbose, :ignore, :event
21
+ attr_accessor :default_scale_factors, :verbose, :show_table_stats, :ignore, :event,
22
+ :backtrace_cleaner, :backtrace_length, :truncate_query_size
10
23
 
11
- def failure_message(queries)
24
+ attr_reader :default_matching
25
+
26
+ def failure_message(queries) # rubocop:disable Metrics/MethodLength
12
27
  msg = ["Expected to make the same number of queries, but got:\n"]
13
28
  queries.each do |(scale, data)|
14
29
  msg << " #{data.size} for N=#{scale}\n"
15
- msg << data.map { |sql| " #{sql}\n" }.join.to_s if verbose
16
30
  end
31
+
32
+ msg.concat(table_usage_stats(queries.map(&:last))) if show_table_stats
33
+
34
+ if verbose
35
+ queries.each do |(scale, data)|
36
+ msg << "Queries for N=#{scale}\n"
37
+ msg << data.map { |sql| " #{truncate_query(sql)}\n" }.join.to_s
38
+ end
39
+ end
40
+
17
41
  msg.join
18
42
  end
43
+
44
+ def table_usage_stats(runs) # rubocop:disable Metrics/MethodLength
45
+ msg = ["Unmatched query numbers by tables:\n"]
46
+
47
+ before, after = runs.map do |queries|
48
+ queries.group_by do |query|
49
+ matches = query.match(EXTRACT_TABLE_RXP)
50
+ next unless matches
51
+
52
+ " #{matches[2]} (#{QUERY_PART_TO_TYPE[matches[1].downcase]})"
53
+ end.transform_values(&:count)
54
+ end
55
+
56
+ before.keys.each do |k|
57
+ next if before[k] == after[k]
58
+
59
+ msg << "#{k}: #{before[k]} != #{after[k]}\n"
60
+ end
61
+
62
+ msg
63
+ end
64
+
65
+ def default_matching=(val)
66
+ unless val
67
+ @default_matching = nil
68
+ return
69
+ end
70
+
71
+ @default_matching =
72
+ if val.is_a?(Regexp)
73
+ val
74
+ else
75
+ Regexp.new(val, Regexp::MULTILINE | Regexp::IGNORECASE)
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def truncate_query(sql)
82
+ return sql unless truncate_query_size
83
+
84
+ # Only truncate query, leave tracing (if any) as is
85
+ parts = sql.split(/(\s+↳)/)
86
+
87
+ parts[0] =
88
+ if truncate_query_size < 4
89
+ "..."
90
+ else
91
+ parts[0][0..(truncate_query_size - 4)] + "..."
92
+ end
93
+
94
+ parts.join
95
+ end
19
96
  end
20
97
 
21
98
  # Scale factors to use.
@@ -23,7 +100,10 @@ module NPlusOneControl
23
100
  self.default_scale_factors = [2, 3]
24
101
 
25
102
  # Print performed queries if true
26
- self.verbose = ENV['NPLUSONE_VERBOSE'] == '1'
103
+ self.verbose = ENV["NPLUSONE_VERBOSE"] == "1"
104
+
105
+ # Print table hits difference
106
+ self.show_table_stats = true
27
107
 
28
108
  # Ignore matching queries
29
109
  self.ignore = /^(BEGIN|COMMIT|SAVEPOINT|RELEASE)/
@@ -31,5 +111,16 @@ module NPlusOneControl
31
111
  # ActiveSupport notifications event to track queries.
32
112
  # We track ActiveRecord event by default,
33
113
  # but can also track rom-rb events ('sql.rom') as well.
34
- self.event = 'sql.active_record'
114
+ self.event = "sql.active_record"
115
+
116
+ # Default query filtering applied if none provided explicitly
117
+ self.default_matching = ENV["NPLUSONE_FILTER"] || /^SELECT/i
118
+
119
+ # Truncate queries in verbose mode to fit the length
120
+ self.truncate_query_size = ENV["NPLUSONE_TRUNCATE"]&.to_i
121
+
122
+ # Define the number of backtrace lines to show
123
+ self.backtrace_length = ENV.fetch("NPLUSONE_BACKTRACE", 1).to_i
35
124
  end
125
+
126
+ require "n_plus_one_control/railtie" if defined?(Rails::Railtie)
@@ -3,7 +3,7 @@
3
3
  module NPlusOneControl
4
4
  # Runs code for every scale factor
5
5
  # and returns collected queries.
6
- module Executor
6
+ class Executor
7
7
  # Subscribes to ActiveSupport notifications and collect matching queries.
8
8
  class Collector
9
9
  def initialize(pattern)
@@ -19,46 +19,86 @@ module NPlusOneControl
19
19
  @queries
20
20
  end
21
21
 
22
- def callback(_name, _start, _finish, _message_id, values)
22
+ def callback(_name, _start, _finish, _message_id, values) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/LineLength
23
23
  return if %w[CACHE SCHEMA].include? values[:name]
24
- @queries << values[:sql] if @pattern.nil? || (values[:sql] =~ @pattern)
25
- end
26
- end
27
24
 
28
- class << self
29
- attr_accessor :transaction_begin
30
- attr_accessor :transaction_rollback
25
+ return unless @pattern.nil? || (values[:sql] =~ @pattern)
31
26
 
32
- def call(population:, scale_factors: nil, matching: nil)
33
- raise ArgumentError, "Block is required!" unless block_given?
27
+ query = values[:sql]
34
28
 
35
- results = []
36
- collector = Collector.new(matching)
29
+ if NPlusOneControl.backtrace_cleaner && NPlusOneControl.verbose
30
+ source = extract_query_source_location(caller)
37
31
 
38
- (scale_factors || NPlusOneControl.default_scale_factors).each do |scale|
39
- with_transaction do
40
- population.call(scale)
41
- results << [scale, collector.call { yield }]
42
- end
32
+ query = "#{query}\n ↳ #{source.join("\n")}" unless source.empty?
43
33
  end
44
- results
34
+
35
+ @queries << query
45
36
  end
46
37
 
47
38
  private
48
39
 
49
- def with_transaction
50
- transaction_begin.call
51
- yield
52
- ensure
53
- transaction_rollback.call
40
+ def extract_query_source_location(locations)
41
+ NPlusOneControl.backtrace_cleaner.call(locations.lazy)
42
+ .take(NPlusOneControl.backtrace_length).to_a
54
43
  end
55
44
  end
56
45
 
46
+ class << self
47
+ attr_accessor :transaction_begin
48
+ attr_accessor :transaction_rollback
49
+ end
50
+
51
+ attr_reader :current_scale
52
+
57
53
  self.transaction_begin = -> do
58
54
  ActiveRecord::Base.connection.begin_transaction(joinable: false)
59
55
  end
56
+
60
57
  self.transaction_rollback = -> do
61
58
  ActiveRecord::Base.connection.rollback_transaction
62
59
  end
60
+
61
+ def initialize(population: nil, scale_factors: nil, matching: nil)
62
+ @population = population
63
+ @scale_factors = scale_factors
64
+ @matching = matching
65
+ end
66
+
67
+ # rubocop:disable Metrics/MethodLength
68
+ def call
69
+ raise ArgumentError, "Block is required!" unless block_given?
70
+
71
+ results = []
72
+ collector = Collector.new(matching)
73
+
74
+ (scale_factors || NPlusOneControl.default_scale_factors).each do |scale|
75
+ @current_scale = scale
76
+ with_transaction do
77
+ population&.call(scale)
78
+ results << [scale, collector.call { yield }]
79
+ end
80
+ end
81
+ results
82
+ end
83
+ # rubocop:enable Metrics/MethodLength
84
+
85
+ private
86
+
87
+ def with_transaction
88
+ transaction_begin.call
89
+ yield
90
+ ensure
91
+ transaction_rollback.call
92
+ end
93
+
94
+ def transaction_begin
95
+ self.class.transaction_begin
96
+ end
97
+
98
+ def transaction_rollback
99
+ self.class.transaction_rollback
100
+ end
101
+
102
+ attr_reader :population, :scale_factors, :matching
63
103
  end
64
104
  end