n_plus_one_control 0.2.1 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
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