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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +169 -0
- data/README.md +406 -183
- data/lib/generators/prosopite/install_generator.rb +53 -0
- data/lib/generators/prosopite/templates/prosopite.rb.tt +18 -0
- data/lib/prosopite/fixes.rb +181 -0
- data/lib/prosopite/middleware/rack.rb +212 -2
- data/lib/prosopite/middleware/sidekiq.rb +2 -2
- data/lib/prosopite/minitest.rb +23 -0
- data/lib/prosopite/rspec.rb +20 -0
- data/lib/prosopite/version.rb +1 -1
- data/lib/prosopite.rb +478 -95
- metadata +22 -24
- data/.github/workflows/ci.yml +0 -15
- data/.gitignore +0 -8
- data/Gemfile +0 -8
- data/Gemfile.lock +0 -152
- data/Rakefile +0 -9
- data/prosopite.gemspec +0 -34
|
@@ -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.
|
|
10
|
-
|
|
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(
|
|
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
|
data/lib/prosopite/version.rb
CHANGED