prosopite 2.1.2 → 2.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.
@@ -0,0 +1,53 @@
1
+ require 'rails/generators'
2
+
3
+ module Prosopite
4
+ module Generators
5
+ # rails generate prosopite:install
6
+ class InstallGenerator < Rails::Generators::Base
7
+ source_root File.expand_path('templates', __dir__)
8
+
9
+ desc 'Creates config/initializers/prosopite.rb and scans every test for N+1 queries'
10
+
11
+ def create_initializer
12
+ template 'prosopite.rb', 'config/initializers/prosopite.rb'
13
+ end
14
+
15
+ def scan_tests
16
+ spec_helper = %w[spec/rails_helper.rb spec/spec_helper.rb].find { |path| File.exist?(File.join(destination_root, path)) }
17
+
18
+ if spec_helper
19
+ configure = File.read(File.join(destination_root, spec_helper))[/RSpec\.configure do \|(\w+)\|\r?\n/]
20
+ return say("Scan each spec in #{spec_helper}, see https://github.com/charkost/prosopite#tests") unless configure
21
+
22
+ config = configure[/\|(\w+)\|/, 1]
23
+ inject_into_file spec_helper, after: configure do
24
+ " #{config}.before { |example| Prosopite.scan(example.location) unless example.metadata[:prosopite] == false }\n" \
25
+ " #{config}.after { Prosopite.finish }\n"
26
+ end
27
+ elsif File.exist?(File.join(destination_root, 'test/test_helper.rb'))
28
+ append_to_file 'test/test_helper.rb', <<~RUBY
29
+
30
+ class ActiveSupport::TestCase
31
+ setup { Prosopite.scan("\#{self.class}#\#{name}") }
32
+ teardown { Prosopite.finish }
33
+ end
34
+ RUBY
35
+ end
36
+ end
37
+
38
+ def mention_pg_query
39
+ database = File.join(destination_root, 'config/database.yml')
40
+ return unless File.exist?(database) && File.read(database).include?('postgresql')
41
+
42
+ say "With PostgreSQL, gem 'pg_query' makes the fingerprints of queries more precise, if it installs on your systems"
43
+ end
44
+
45
+ private
46
+
47
+ def factory_bot?
48
+ gemfile = File.join(destination_root, 'Gemfile')
49
+ File.exist?(gemfile) && File.read(gemfile).match?(/['"]factory_bot(_rails)?['"]/)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,18 @@
1
+ # Finds N+1 queries in development and in tests, see https://github.com/charkost/prosopite
2
+ if Rails.env.development?
3
+ # In tests, the scan of each test covers its requests
4
+ require 'prosopite/middleware/rack'
5
+ Rails.configuration.middleware.use(Prosopite::Middleware::Rack)
6
+ end
7
+
8
+ Rails.application.config.after_initialize do
9
+ if Rails.env.development?
10
+ Prosopite.add_footer = true
11
+ elsif Rails.env.test?
12
+ Prosopite.raise = true
13
+ <% if factory_bot? -%>
14
+ # Leave out the N+1 queries of factories, which only set up the tests
15
+ Prosopite.pause_during('factory_bot.run_factory')
16
+ <% end -%>
17
+ end
18
+ end
@@ -0,0 +1,181 @@
1
+ module Prosopite
2
+ # Suggests fixes for an N+1 from the associations whose loading runs queries like its
3
+ # own: on the table of the associated model, with a single condition on the foreign key
4
+ # of the association, or on the primary key for belongs_to. Other queries, like ones
5
+ # with more conditions, aggregates or joins, run whether an association is loaded or not.
6
+ module Fixes
7
+ # Comments around the query, e.g. of query_log_tags
8
+ LEADING_COMMENTS = %r{\A\s*(?:/\*.*?\*/\s*)+}m
9
+ TRAILING_COMMENTS = %r{\s*(?:/\*.*?\*/\s*)+\z}m
10
+ # A query that loads records, and the table it loads them from
11
+ LOAD = %r{\A\s*(?:/\*.*?\*/\s*)*SELECT\s+(?:DISTINCT\s+)?(?:[`"]?\w+[`"]?\.)?\*\s+FROM\s+[`"]?(\w+)[`"]?(?:\s|\z)}im
12
+ QUERY = /\A
13
+ SELECT\s+(?<select>.+?)\s+
14
+ FROM\s+(?<table>[`"]?\w+[`"]?)\s+
15
+ WHERE\s+(?<column>(?:[`"]?\w+[`"]?\.)?[`"]?\w+[`"]?)\s*
16
+ (?:=\s*(?:\$\d+|\?|-?\d+|'[^']*'|:\w+)|IN\s*\([^()]*\))
17
+ (?:\s+ORDER\s+BY\s+[^;]+?)?
18
+ (?:\s+LIMIT\s+\S+)?
19
+ (?:\s+OFFSET\s+\S+)?
20
+ \s*;?\s*
21
+ \z/xim
22
+ COLUMN = /(?:[`"]?\w+[`"]?\.)?[`"]?\w+[`"]?/
23
+ # Associations that can't be told apart without where their records were loaded
24
+ MAX_AMBIGUOUS = 3
25
+
26
+ class << self
27
+ # The block returns [order, location] for where records of a model were loaded, with
28
+ # order telling how recent that was, to pick the associations of the records looped on.
29
+ # Without such records, loop_table is the table of the last records loaded before the
30
+ # N+1, and when no association is of it, the N+1 loops on records that no suggestion
31
+ # fits, e.g. through a polymorphic association.
32
+ def for(query, index = self.index, loop_table: nil)
33
+ match = parse(query)
34
+ kind = match && kind(match[:select])
35
+ return [] unless kind
36
+
37
+ reflections = associations(query, index).select { |reflection| kind == :load || reflection.collection? }
38
+ loads = reflections.to_h { |reflection| [reflection, block_given? ? yield(reflection.active_record) : nil] }
39
+ latest = loads.values.compact.map(&:first).max
40
+
41
+ if latest
42
+ reflections = reflections.select { |reflection| loads[reflection]&.first == latest }
43
+ elsif reflections.size > MAX_AMBIGUOUS ||
44
+ (loop_table && reflections.none? { |reflection| reflection.active_record.table_name == loop_table })
45
+ return []
46
+ end
47
+
48
+ reflections.map { |reflection| suggestion(reflection, kind, loads[reflection]&.last) }
49
+ end
50
+
51
+ def associations(query, index = self.index)
52
+ match = parse(query)
53
+ return [] unless match
54
+
55
+ table = unquote(match[:table])
56
+ qualifier, column = match[:column].split('.', 2).map { |name| unquote(name) }
57
+ qualifier, column = nil, qualifier unless column
58
+ return [] if qualifier && qualifier != table
59
+
60
+ index.fetch([table, column], [])
61
+ end
62
+
63
+ # The associations by the table and the column of their queries, built again
64
+ # only when the models change, e.g. when reloading code in development
65
+ def index
66
+ models = ActiveRecord::Base.descendants
67
+ models_key = models.map(&:object_id)
68
+ return @index if @index_models_key == models_key
69
+
70
+ index = {}
71
+ models.flat_map(&:reflect_on_all_associations).uniq.each do |reflection|
72
+ key = query_key(reflection)
73
+ (index[key] ||= []) << reflection if key
74
+ end
75
+ @index = index
76
+ @index_models_key = models_key
77
+ index
78
+ end
79
+
80
+ private
81
+
82
+ def parse(query)
83
+ query.sub(LEADING_COMMENTS, '').sub(TRAILING_COMMENTS, '').match(QUERY)
84
+ end
85
+
86
+ def kind(select)
87
+ if select.match?(/\A(?:[`"]?\w+[`"]?\.)?\*\z/)
88
+ :load
89
+ elsif select.match?(/\ACOUNT\(/i)
90
+ :count
91
+ elsif select.match?(/\A1 AS one\z/i)
92
+ :exists
93
+ elsif select.match?(/\A#{COLUMN}(?:\s*,\s*#{COLUMN})*\z/)
94
+ # pluck, which uses the records of a loaded association
95
+ :load
96
+ end
97
+ end
98
+
99
+ def suggestion(reflection, kind, location)
100
+ owner = reflection.active_record
101
+ fix = "#{owner.name}.includes(:#{reflection.name})"
102
+ fix += " where the #{owner.table_name} are loaded, at #{location}" if location
103
+
104
+ case kind
105
+ when :count then "#{fix}, then #{reflection.name}.size; or a counter cache"
106
+ when :exists then "#{fix}, then #{reflection.name}.any?"
107
+ else fix
108
+ end
109
+ end
110
+
111
+ def unquote(name)
112
+ name.delete('`"')
113
+ end
114
+
115
+ def query_key(reflection)
116
+ owner = reflection.active_record.name
117
+ # Through associations join, Rails defines HABTM_ models for its join tables, and
118
+ # scopes and subclasses of single table inheritance add conditions to the queries
119
+ return if reflection.through_reflection? || reflection.polymorphic? || owner.nil? || owner.include?('HABTM_')
120
+ return if reflection.scope || !reflection.klass.descends_from_active_record?
121
+
122
+ column = reflection.belongs_to? ? reflection.association_primary_key : reflection.foreign_key
123
+ [reflection.klass.table_name, column.to_s]
124
+ rescue StandardError
125
+ # An association to a class that doesn't exist, isn't a model, or has no database
126
+ nil
127
+ end
128
+ end
129
+
130
+ # Where the records that N+1s loop on were loaded: the last query on their table before
131
+ # each N+1, with its order among the queries of the scan
132
+ class Loads
133
+ def initialize(state, backtrace_cleaner)
134
+ @holder = state[:prosopite_query_holder]
135
+ @callers = state[:prosopite_query_caller]
136
+ @backtrace_cleaner = backtrace_cleaner
137
+ # In the order of their first query. A copy, as an ActionController::Live action may
138
+ # still be adding to them.
139
+ @keys = @holder.keys
140
+ @positions = @keys.each_with_index.to_h
141
+ @found = {}
142
+ end
143
+
144
+ def at(location_key, table)
145
+ @found.fetch([location_key, table]) { |key| @found[key] = find(location_key, table) }
146
+ end
147
+
148
+ # The table of the last records loaded by a query that ran once before the N+1,
149
+ # which likely loaded the records it loops on
150
+ def loop_table(location_key)
151
+ before(location_key).each do |position|
152
+ queries = @holder[@keys[position]]
153
+ table = queries.first[LOAD, 1] if queries.size == 1
154
+ return table if table
155
+ end
156
+ nil
157
+ end
158
+
159
+ private
160
+
161
+ # The positions of the queries before the N+1, the latest first
162
+ def before(location_key)
163
+ (@positions.fetch(location_key, @keys.size) - 1).downto(0)
164
+ end
165
+
166
+ def find(location_key, table)
167
+ before(location_key).each do |position|
168
+ key = @keys[position]
169
+ next unless @holder[key].first[LOAD, 1] == table
170
+
171
+ # The call stacks of single queries are kept up to a limit
172
+ caller = @callers[key]
173
+ frame = caller && @backtrace_cleaner.clean(caller.map(&:to_s)).first
174
+ return [position, frame&.sub(/:in\b.*\z/, '')]
175
+ end
176
+
177
+ nil
178
+ end
179
+ end
180
+ end
181
+ end
@@ -1,16 +1,226 @@
1
+ require 'json'
2
+
1
3
  module Prosopite
2
4
  module Middleware
3
5
  class Rack
6
+ # Carries the N+1 queries of responses that aren't pages, e.g. of fetch requests
7
+ HEADER = 'x-prosopite-notifications'
8
+ # Proxies and servers limit the size of all headers, e.g. nginx to 4 KB by default
9
+ HEADER_LIMIT = 2048
10
+ HEADER_MESSAGE_LIMIT = 400
11
+
12
+ FOOTER_STYLE = <<~CSS.freeze
13
+ #prosopite-footer { position: fixed; left: 0; bottom: 0; z-index: 2147483647; box-sizing: border-box;
14
+ max-width: 100vw; max-height: 50vh; overflow: auto; margin: 0; padding: 4px 8px; text-align: left;
15
+ background: #fff5f5; color: #a40000; border: 1px solid #a40000; border-width: 1px 1px 0 0;
16
+ border-top-right-radius: 6px; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
17
+ #prosopite-footer summary { cursor: pointer; font-weight: bold; }
18
+ #prosopite-footer pre { margin: 4px 0 0; white-space: pre-wrap; font: inherit; color: #222; }
19
+ CSS
20
+
21
+ # Shows the N+1 queries of the fetch and XHR requests of a page, like the ones of
22
+ # Turbo, from their header. Turbo runs it again on every visit, so it runs once.
23
+ REQUESTS_SCRIPT = <<~JS.freeze
24
+ (function (showFooter, showConsole) {
25
+ if (window.prosopite) return;
26
+ window.prosopite = true;
27
+
28
+ function show(value) {
29
+ var data;
30
+ try { data = JSON.parse(value); } catch (e) { return; }
31
+
32
+ if (showConsole) data.messages.forEach(function (message) { console.warn(message); });
33
+ if (!showFooter) return;
34
+
35
+ var footer = document.getElementById('prosopite-footer');
36
+ if (!footer) {
37
+ footer = document.createElement('details');
38
+ footer.id = 'prosopite-footer';
39
+ footer.appendChild(document.createElement('summary'));
40
+ footer.appendChild(document.createElement('pre'));
41
+ document.body.appendChild(footer);
42
+ }
43
+ var count = Number(footer.getAttribute('data-count') || 0) + data.count;
44
+ footer.setAttribute('data-count', count);
45
+ footer.querySelector('summary').textContent =
46
+ 'Prosopite: ' + count + ' N+1 ' + (count === 1 ? 'query' : 'queries') + ' detected';
47
+ footer.querySelector('pre').textContent +=
48
+ data.messages.join('') + (data.count > data.messages.length ? '...\\n\\n' : '');
49
+ }
50
+
51
+ var fetch = window.fetch;
52
+ if (fetch) {
53
+ window.fetch = function () {
54
+ return fetch.apply(window, arguments).then(function (response) {
55
+ var value = response.headers.get('#{HEADER}');
56
+ if (value) show(value);
57
+ return response;
58
+ });
59
+ };
60
+ }
61
+
62
+ var open = XMLHttpRequest.prototype.open;
63
+ XMLHttpRequest.prototype.open = function () {
64
+ if (!this.prosopite) {
65
+ this.prosopite = true;
66
+ this.addEventListener('load', function () {
67
+ // Reading a header that a cross-origin response doesn't expose logs an error
68
+ if (!/^#{HEADER}:/im.test(this.getAllResponseHeaders())) return;
69
+ show(this.getResponseHeader('#{HEADER}'));
70
+ });
71
+ }
72
+ return open.apply(this, arguments);
73
+ };
74
+ })
75
+ JS
76
+
4
77
  def initialize(app)
5
78
  @app = app
6
79
  end
7
80
 
8
81
  def call(env)
9
- Prosopite.scan
10
- @app.call(env)
82
+ return @app.call(env) if Prosopite.disabled?
83
+
84
+ req = ::Rack::Request.new(env)
85
+ Prosopite.scan("#{req.request_method} #{req.path}")
86
+ status, headers, body = @app.call(env)
87
+ notifications = Prosopite.finish || []
88
+
89
+ if Prosopite.add_footer || Prosopite.browser_console
90
+ body, page = add_to_page(env, headers, body, notifications)
91
+ headers[HEADER] = header_value(notifications) if !page && notifications.any?
92
+ end
93
+
94
+ [status, headers, body]
11
95
  ensure
12
96
  Prosopite.finish
13
97
  end
98
+
99
+ private
100
+
101
+ # Adds the N+1 queries and the script for later requests to a page. Responses that
102
+ # aren't pages, like the ones of Turbo frames and streams, return false.
103
+ def add_to_page(env, headers, body, notifications)
104
+ return [body, false] if env['HTTP_TURBO_FRAME'] || !injectable?(headers, body)
105
+
106
+ parts = body.to_ary
107
+ return [body, false] unless parts
108
+
109
+ html = String.new(encoding: Encoding::BINARY)
110
+ parts.each { |part| html << part.b }
111
+
112
+ index = html.rindex(%r{</body>}i)
113
+ return [parts, false] unless index
114
+
115
+ html.insert(index, page_html(env, headers, notifications).b)
116
+ # In the encoding of the page again, e.g. for middleware that add to it after this one
117
+ html.force_encoding(parts.first.encoding)
118
+ html.force_encoding(Encoding::BINARY) unless html.valid_encoding?
119
+
120
+ content_length = headers.keys.find { |k| k.casecmp?('content-length') }
121
+ headers[content_length] = html.bytesize.to_s if content_length
122
+
123
+ [[html], true]
124
+ end
125
+
126
+ # Only buffered bodies can be changed: Rack allows middleware to read a body
127
+ # through to_ary, but not to iterate it, which would break streaming.
128
+ def injectable?(headers, body)
129
+ body.respond_to?(:to_ary) &&
130
+ header(headers, 'content-type').to_s.include?('text/html') &&
131
+ !header(headers, 'content-disposition').to_s.include?('attachment') &&
132
+ header(headers, 'content-encoding').nil? &&
133
+ header(headers, 'transfer-encoding').nil?
134
+ end
135
+
136
+ def header(headers, name)
137
+ key = headers.keys.find { |k| k.casecmp?(name) }
138
+ headers[key] if key
139
+ end
140
+
141
+ def page_html(env, headers, notifications)
142
+ nonce = content_security_policy_nonce(env)
143
+ nonce_attr = nonce ? %( nonce="#{::Rack::Utils.escape_html(nonce)}") : ''
144
+ policy = content_security_policy(env, headers)
145
+ messages = notifications.map { |notification| Prosopite.notification_message(notification) }
146
+
147
+ html = String.new('')
148
+ if Prosopite.add_footer
149
+ html << "<style#{nonce_attr}>#{FOOTER_STYLE}</style>" if inline_allowed?(policy, 'style-src', nonce)
150
+ html << footer_html(messages) if messages.any?
151
+ end
152
+
153
+ # A blocked script would also be reported
154
+ return html unless inline_allowed?(policy, 'script-src', nonce)
155
+
156
+ script = String.new('')
157
+ script << messages.map { |message| "console.warn(#{js_string(message)});" }.join if Prosopite.browser_console
158
+ script << "#{REQUESTS_SCRIPT}(#{!!Prosopite.add_footer}, #{!!Prosopite.browser_console});"
159
+ html << "<script#{nonce_attr}>#{script}</script>"
160
+ end
161
+
162
+ # The Content Security Policy that the response will have. Rails adds its own outside
163
+ # this middleware, unless the response has one, so it's built the same way here.
164
+ def content_security_policy(env, headers)
165
+ policy = header(headers, 'content-security-policy')
166
+ rails_policy = env['action_dispatch.content_security_policy']
167
+ return policy if policy || rails_policy.nil? || header(headers, 'content-security-policy-report-only')
168
+
169
+ request = ::ActionDispatch::Request.new(env)
170
+ return if request.content_security_policy_report_only
171
+
172
+ rails_policy.build(request.controller_instance || request, request.content_security_policy_nonce,
173
+ request.content_security_policy_nonce_directives)
174
+ end
175
+
176
+ # Whether the policy lets an inline element of the directive run, with the nonce if any.
177
+ # Browsers ignore 'unsafe-inline' in directives with nonces, hashes or 'strict-dynamic'.
178
+ def inline_allowed?(policy, directive, nonce)
179
+ return true unless policy
180
+
181
+ directives = policy.split(';').map(&:split)
182
+ sources = directives.assoc("#{directive}-elem") || directives.assoc(directive) || directives.assoc('default-src')
183
+ return true unless sources
184
+ return true if nonce && sources.include?("'nonce-#{nonce}'")
185
+
186
+ sources.include?("'unsafe-inline'") && sources.none? { |source| source.match?(/\A'(?:nonce-|sha\d+-|strict-dynamic')/) }
187
+ end
188
+
189
+ def footer_html(messages)
190
+ summary = "Prosopite: #{messages.size} N+1 #{messages.size == 1 ? 'query' : 'queries'} detected"
191
+
192
+ "<details id=\"prosopite-footer\" data-count=\"#{messages.size}\"><summary>#{summary}</summary>" \
193
+ "<pre>#{::Rack::Utils.escape_html(messages.join)}</pre></details>"
194
+ end
195
+
196
+ # As many messages as fit, as ASCII JSON, with the count of all the N+1 queries
197
+ def header_value(notifications)
198
+ messages = []
199
+ notifications.each do |notification|
200
+ message = Prosopite.notification_message(notification)
201
+ message = "#{message[0, HEADER_MESSAGE_LIMIT]}...\n\n" if message.size > HEADER_MESSAGE_LIMIT
202
+ break if header_json(notifications.size, messages + [message]).bytesize > HEADER_LIMIT
203
+
204
+ messages << message
205
+ end
206
+
207
+ header_json(notifications.size, messages)
208
+ end
209
+
210
+ def header_json(count, messages)
211
+ JSON.generate({ count: count, messages: messages }, ascii_only: true)
212
+ end
213
+
214
+ # JSON, with the characters that could close the script tag escaped
215
+ def js_string(str)
216
+ JSON.generate(str).gsub(/[<>&

]/) { |c| format('\\u%04x', c.ord) }
217
+ end
218
+
219
+ def content_security_policy_nonce(env)
220
+ return unless env['action_dispatch.content_security_policy_nonce_generator']
221
+
222
+ ::ActionDispatch::Request.new(env).content_security_policy_nonce
223
+ end
14
224
  end
15
225
  end
16
226
  end
@@ -3,8 +3,8 @@ module Prosopite
3
3
  class Sidekiq
4
4
  include ::Sidekiq::ServerMiddleware
5
5
 
6
- def call(_worker, _msg, _queue)
7
- Prosopite.scan
6
+ def call(worker, msg, queue)
7
+ Prosopite.scan("#{worker.class.name} JID-#{msg['jid']} queue=#{queue}")
8
8
  yield
9
9
  ensure
10
10
  Prosopite.finish
@@ -0,0 +1,23 @@
1
+ require 'minitest'
2
+ require 'prosopite'
3
+
4
+ module Prosopite
5
+ # Assertions for Minitest, included in Minitest::Test
6
+ module Assertions
7
+ def assert_no_n_plus_one_queries(&block)
8
+ n_plus_one_queries = Prosopite.n_plus_one_queries(&block)
9
+ message = lambda do
10
+ "Expected no N+1 queries, but there were:\n\n" +
11
+ n_plus_one_queries.map { |notification| Prosopite.notification_message(notification) }.join
12
+ end
13
+
14
+ assert n_plus_one_queries.empty?, message
15
+ end
16
+
17
+ def assert_n_plus_one_queries(&block)
18
+ refute Prosopite.n_plus_one_queries(&block).empty?, 'Expected N+1 queries, but there were none'
19
+ end
20
+ end
21
+ end
22
+
23
+ Minitest::Test.include(Prosopite::Assertions)
@@ -0,0 +1,20 @@
1
+ require 'prosopite'
2
+
3
+ # expect { ... }.not_to have_n_plus_one_queries
4
+ RSpec::Matchers.define :have_n_plus_one_queries do
5
+ supports_block_expectations
6
+
7
+ match do |block|
8
+ @n_plus_one_queries = Prosopite.n_plus_one_queries(&block)
9
+ @n_plus_one_queries.any?
10
+ end
11
+
12
+ failure_message do
13
+ 'expected N+1 queries, but there were none'
14
+ end
15
+
16
+ failure_message_when_negated do
17
+ "expected no N+1 queries, but there were:\n\n" +
18
+ @n_plus_one_queries.map { |notification| Prosopite.notification_message(notification) }.join
19
+ end
20
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Prosopite
4
- VERSION = "2.1.2"
4
+ VERSION = "2.5.0"
5
5
  end