and_one 0.1.0 → 0.3.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/.githooks/pre-commit +10 -0
- data/CHANGELOG.md +18 -0
- data/README.md +29 -10
- data/lib/and_one/active_job_hook.rb +2 -2
- data/lib/and_one/aggregate.rb +6 -9
- data/lib/and_one/association_resolver.rb +29 -36
- data/lib/and_one/console.rb +7 -9
- data/lib/and_one/detection.rb +3 -3
- data/lib/and_one/detector.rb +12 -12
- data/lib/and_one/dev_toast.rb +149 -0
- data/lib/and_one/dev_ui.rb +49 -51
- data/lib/and_one/fingerprint.rb +1 -1
- data/lib/and_one/formatter.rb +11 -13
- data/lib/and_one/ignore_file.rb +3 -3
- data/lib/and_one/json_formatter.rb +1 -1
- data/lib/and_one/matchers.rb +21 -21
- data/lib/and_one/middleware.rb +39 -1
- data/lib/and_one/railtie.rb +4 -1
- data/lib/and_one/rspec.rb +5 -3
- data/lib/and_one/scan_helper.rb +2 -2
- data/lib/and_one/sidekiq_middleware.rb +2 -2
- data/lib/and_one/version.rb +1 -1
- data/lib/and_one.rb +40 -40
- metadata +7 -5
- data/TODO.md +0 -52
data/lib/and_one/dev_ui.rb
CHANGED
|
@@ -5,12 +5,10 @@ module AndOne
|
|
|
5
5
|
# session. Mount at `/__and_one` in development to get a mini dashboard
|
|
6
6
|
# for N+1 queries with fix suggestions.
|
|
7
7
|
#
|
|
8
|
-
# Requires `aggregate_mode = true` to collect detections across requests.
|
|
9
|
-
#
|
|
10
8
|
# Usage (manual):
|
|
11
9
|
# app.middleware.use AndOne::DevUI
|
|
12
10
|
#
|
|
13
|
-
# Or it's auto-mounted by the Railtie in development
|
|
11
|
+
# Or it's auto-mounted by the Railtie in development.
|
|
14
12
|
class DevUI
|
|
15
13
|
MOUNT_PATH = "/__and_one"
|
|
16
14
|
|
|
@@ -28,8 +26,8 @@ module AndOne
|
|
|
28
26
|
|
|
29
27
|
private
|
|
30
28
|
|
|
31
|
-
def serve_dashboard(
|
|
32
|
-
entries = AndOne.
|
|
29
|
+
def serve_dashboard(_env)
|
|
30
|
+
entries = AndOne.aggregate.detections
|
|
33
31
|
|
|
34
32
|
html = render_html(entries)
|
|
35
33
|
[200, { "content-type" => "text/html; charset=utf-8" }, [html]]
|
|
@@ -37,49 +35,48 @@ module AndOne
|
|
|
37
35
|
|
|
38
36
|
def render_html(entries)
|
|
39
37
|
rows = if entries.empty?
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
loading_hint = suggestion&.loading_strategy_hint ? h(suggestion.loading_strategy_hint) : ""
|
|
38
|
+
<<~HTML
|
|
39
|
+
<tr>
|
|
40
|
+
<td colspan="6" class="empty">
|
|
41
|
+
No N+1 queries detected yet.
|
|
42
|
+
</td>
|
|
43
|
+
</tr>
|
|
44
|
+
HTML
|
|
45
|
+
else
|
|
46
|
+
entries.map.with_index do |(fp, entry), i|
|
|
47
|
+
det = entry.detection
|
|
48
|
+
suggestion = begin
|
|
49
|
+
AndOne::AssociationResolver.resolve(det, det.raw_caller_strings)
|
|
50
|
+
rescue StandardError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
fix = suggestion&.actionable? ? h(suggestion.fix_hint) : "—"
|
|
54
|
+
strict_hint = suggestion&.strict_loading_hint ? h(suggestion.strict_loading_hint) : ""
|
|
55
|
+
loading_hint = suggestion&.loading_strategy_hint ? h(suggestion.loading_strategy_hint) : ""
|
|
59
56
|
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
origin = det.origin_frame ? format_frame(det.origin_frame) : "—"
|
|
58
|
+
fix_loc = det.fix_location ? format_frame(det.fix_location) : "—"
|
|
62
59
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
60
|
+
<<~HTML
|
|
61
|
+
<tr>
|
|
62
|
+
<td>#{i + 1}</td>
|
|
63
|
+
<td><code>#{h(det.table_name || "unknown")}</code></td>
|
|
64
|
+
<td>#{entry.occurrences}</td>
|
|
65
|
+
<td><code class="sql">#{h(truncate(det.sample_query, 200))}</code></td>
|
|
66
|
+
<td>
|
|
67
|
+
<div class="origin">#{h(origin)}</div>
|
|
68
|
+
<div class="fix-loc">⇒ #{h(fix_loc)}</div>
|
|
69
|
+
</td>
|
|
70
|
+
<td>
|
|
71
|
+
<div class="suggestion">#{fix}</div>
|
|
72
|
+
#{"<div class=\"strategy\">#{loading_hint}</div>" unless loading_hint.empty?}
|
|
73
|
+
#{"<div class=\"strict\">#{strict_hint}</div>" unless strict_hint.empty?}
|
|
74
|
+
<div class="fingerprint">#{h(fp)}</div>
|
|
75
|
+
</td>
|
|
76
|
+
</tr>
|
|
77
|
+
HTML
|
|
78
|
+
end.join
|
|
79
|
+
end
|
|
83
80
|
|
|
84
81
|
<<~HTML
|
|
85
82
|
<!DOCTYPE html>
|
|
@@ -114,7 +111,7 @@ module AndOne
|
|
|
114
111
|
</head>
|
|
115
112
|
<body>
|
|
116
113
|
<h1>🏀 AndOne — N+1 Dashboard</h1>
|
|
117
|
-
<p class="subtitle">#{entries.size} unique N+1 pattern#{
|
|
114
|
+
<p class="subtitle">#{entries.size} unique N+1 pattern#{"s" if entries.size != 1} detected this session</p>
|
|
118
115
|
<div class="actions">
|
|
119
116
|
<a href="#{MOUNT_PATH}">↻ Refresh</a>
|
|
120
117
|
</div>
|
|
@@ -140,14 +137,15 @@ module AndOne
|
|
|
140
137
|
|
|
141
138
|
def h(text)
|
|
142
139
|
text.to_s
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
140
|
+
.gsub("&", "&")
|
|
141
|
+
.gsub("<", "<")
|
|
142
|
+
.gsub(">", ">")
|
|
143
|
+
.gsub('"', """)
|
|
147
144
|
end
|
|
148
145
|
|
|
149
146
|
def truncate(text, max)
|
|
150
147
|
return text if text.length <= max
|
|
148
|
+
|
|
151
149
|
"#{text[0...max]}..."
|
|
152
150
|
end
|
|
153
151
|
|
data/lib/and_one/fingerprint.rb
CHANGED
|
@@ -22,7 +22,7 @@ module AndOne
|
|
|
22
22
|
normalized.gsub!(/`(\w+)`/, '\1')
|
|
23
23
|
|
|
24
24
|
# Normalize single-quoted string literals
|
|
25
|
-
normalized.gsub!(
|
|
25
|
+
normalized.gsub!("\\'", "")
|
|
26
26
|
normalized.gsub!(/'(?:[^'\\]|\\.)*'/m, "?")
|
|
27
27
|
|
|
28
28
|
# Normalize numbers (standalone, not part of identifiers)
|
data/lib/and_one/formatter.rb
CHANGED
|
@@ -23,7 +23,8 @@ module AndOne
|
|
|
23
23
|
parts = []
|
|
24
24
|
parts << ""
|
|
25
25
|
parts << colorize(SEPARATOR, :red)
|
|
26
|
-
parts << colorize(" 🏀 And One! #{detections.size} N+1 quer#{detections.size == 1 ?
|
|
26
|
+
parts << colorize(" 🏀 And One! #{detections.size} N+1 quer#{detections.size == 1 ? "y" : "ies"} detected", :red,
|
|
27
|
+
:bold)
|
|
27
28
|
parts << colorize(SEPARATOR, :red)
|
|
28
29
|
|
|
29
30
|
detections.each_with_index do |detection, i|
|
|
@@ -38,12 +39,13 @@ module AndOne
|
|
|
38
39
|
|
|
39
40
|
private
|
|
40
41
|
|
|
41
|
-
def format_detection(detection, index)
|
|
42
|
+
def format_detection(detection, index) # rubocop:disable Metrics
|
|
42
43
|
lines = []
|
|
43
44
|
cleaned_bt = clean_backtrace(detection.raw_caller_strings)
|
|
44
45
|
|
|
45
46
|
# Header with count and fingerprint
|
|
46
|
-
lines << colorize(" #{index}) #{detection.count}x repeated query on `#{detection.table_name ||
|
|
47
|
+
lines << colorize(" #{index}) #{detection.count}x repeated query on `#{detection.table_name || "unknown"}`",
|
|
48
|
+
:yellow, :bold)
|
|
47
49
|
lines << colorize(" fingerprint: #{detection.fingerprint}", :dim)
|
|
48
50
|
lines << ""
|
|
49
51
|
|
|
@@ -68,9 +70,8 @@ module AndOne
|
|
|
68
70
|
|
|
69
71
|
# Abbreviated call stack
|
|
70
72
|
lines << colorize(" Call stack:", :cyan)
|
|
71
|
-
cleaned_bt.first(6).each_with_index do |frame,
|
|
72
|
-
|
|
73
|
-
lines << colorize("#{prefix}#{frame}", :dim)
|
|
73
|
+
cleaned_bt.first(6).each_with_index do |frame, _fi|
|
|
74
|
+
lines << colorize(" #{frame}", :dim)
|
|
74
75
|
end
|
|
75
76
|
lines << colorize(" ... (#{cleaned_bt.size - 6} more frames)", :dim) if cleaned_bt.size > 6
|
|
76
77
|
lines << ""
|
|
@@ -80,12 +81,8 @@ module AndOne
|
|
|
80
81
|
if suggestion&.actionable?
|
|
81
82
|
lines << colorize(" 💡 Suggestion:", :cyan, :bold)
|
|
82
83
|
lines << colorize(" #{suggestion.fix_hint}", :green)
|
|
83
|
-
if suggestion.loading_strategy_hint
|
|
84
|
-
|
|
85
|
-
end
|
|
86
|
-
if suggestion.strict_loading_hint
|
|
87
|
-
lines << colorize(" #{suggestion.strict_loading_hint}", :dim)
|
|
88
|
-
end
|
|
84
|
+
lines << colorize(" #{suggestion.loading_strategy_hint}", :green) if suggestion.loading_strategy_hint
|
|
85
|
+
lines << colorize(" #{suggestion.strict_loading_hint}", :dim) if suggestion.strict_loading_hint
|
|
89
86
|
end
|
|
90
87
|
|
|
91
88
|
# Ignore hint
|
|
@@ -98,7 +95,7 @@ module AndOne
|
|
|
98
95
|
|
|
99
96
|
def resolve_suggestion(detection, cleaned_backtrace)
|
|
100
97
|
AssociationResolver.resolve(detection, cleaned_backtrace)
|
|
101
|
-
rescue
|
|
98
|
+
rescue StandardError
|
|
102
99
|
nil
|
|
103
100
|
end
|
|
104
101
|
|
|
@@ -121,6 +118,7 @@ module AndOne
|
|
|
121
118
|
|
|
122
119
|
def truncate_query(sql, max_length: 200)
|
|
123
120
|
return sql if sql.length <= max_length
|
|
121
|
+
|
|
124
122
|
"#{sql[0...max_length]}..."
|
|
125
123
|
end
|
|
126
124
|
|
data/lib/and_one/ignore_file.rb
CHANGED
|
@@ -23,7 +23,7 @@ module AndOne
|
|
|
23
23
|
# fingerprint:abc123def456
|
|
24
24
|
#
|
|
25
25
|
class IgnoreFile
|
|
26
|
-
Rule = Struct.new(:type, :pattern
|
|
26
|
+
Rule = Struct.new(:type, :pattern)
|
|
27
27
|
|
|
28
28
|
attr_reader :rules
|
|
29
29
|
|
|
@@ -90,8 +90,8 @@ module AndOne
|
|
|
90
90
|
def matches_path?(glob, raw_caller_strings)
|
|
91
91
|
# Convert glob to regex: * -> [^/]*, ** -> .*
|
|
92
92
|
regex_str = Regexp.escape(glob)
|
|
93
|
-
.gsub('\*\*',
|
|
94
|
-
.gsub('\*',
|
|
93
|
+
.gsub('\*\*', ".*")
|
|
94
|
+
.gsub('\*', "[^/]*")
|
|
95
95
|
regex = Regexp.new(regex_str)
|
|
96
96
|
raw_caller_strings.any? { |frame| frame.match?(regex) }
|
|
97
97
|
end
|
data/lib/and_one/matchers.rb
CHANGED
|
@@ -15,25 +15,25 @@ module AndOne
|
|
|
15
15
|
#
|
|
16
16
|
module MinitestHelper
|
|
17
17
|
# Assert that the block does NOT trigger any N+1 queries.
|
|
18
|
-
def assert_no_n_plus_one(message = nil, &
|
|
19
|
-
detections = scan_for_n_plus_ones(&
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
18
|
+
def assert_no_n_plus_one(message = nil, &)
|
|
19
|
+
detections = scan_for_n_plus_ones(&)
|
|
20
|
+
|
|
21
|
+
return unless detections.any?
|
|
22
|
+
|
|
23
|
+
formatter = Formatter.new(
|
|
24
|
+
backtrace_cleaner: AndOne.backtrace_cleaner || AndOne.send(:default_backtrace_cleaner)
|
|
25
|
+
)
|
|
26
|
+
detail = formatter.format(detections)
|
|
27
|
+
summary = detections.map do |d|
|
|
28
|
+
"#{d.count} queries to `#{d.table_name || "unknown"}` (expected 1)"
|
|
29
|
+
end.join("; ")
|
|
30
|
+
msg = message || "Expected no N+1 queries, but #{detections.size} detected: #{summary}\n#{detail}"
|
|
31
|
+
flunk(msg)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# Assert that the block DOES trigger N+1 queries (useful for documenting known issues).
|
|
35
|
-
def assert_n_plus_one(message = nil, &
|
|
36
|
-
detections = scan_for_n_plus_ones(&
|
|
35
|
+
def assert_n_plus_one(message = nil, &)
|
|
36
|
+
detections = scan_for_n_plus_ones(&)
|
|
37
37
|
|
|
38
38
|
if detections.empty?
|
|
39
39
|
msg = message || "Expected N+1 queries, but none were detected"
|
|
@@ -45,7 +45,7 @@ module AndOne
|
|
|
45
45
|
|
|
46
46
|
private
|
|
47
47
|
|
|
48
|
-
def scan_for_n_plus_ones(&
|
|
48
|
+
def scan_for_n_plus_ones(&)
|
|
49
49
|
# Temporarily disable raise_on_detect so scan returns detections
|
|
50
50
|
# instead of raising
|
|
51
51
|
previous_raise = AndOne.raise_on_detect
|
|
@@ -54,7 +54,7 @@ module AndOne
|
|
|
54
54
|
AndOne.notifications_callback = nil
|
|
55
55
|
|
|
56
56
|
begin
|
|
57
|
-
AndOne.scan(&
|
|
57
|
+
AndOne.scan(&) || []
|
|
58
58
|
ensure
|
|
59
59
|
AndOne.raise_on_detect = previous_raise
|
|
60
60
|
AndOne.notifications_callback = previous_callback
|
|
@@ -100,9 +100,9 @@ module AndOne
|
|
|
100
100
|
backtrace_cleaner: AndOne.backtrace_cleaner || AndOne.send(:default_backtrace_cleaner)
|
|
101
101
|
)
|
|
102
102
|
detail = formatter.format(@detections)
|
|
103
|
-
summary = @detections.map
|
|
104
|
-
"#{d.count} queries to `#{d.table_name ||
|
|
105
|
-
|
|
103
|
+
summary = @detections.map do |d|
|
|
104
|
+
"#{d.count} queries to `#{d.table_name || "unknown"}` (expected 1)"
|
|
105
|
+
end.join("; ")
|
|
106
106
|
"expected no N+1 queries, but #{@detections.size} detected: #{summary}\n#{detail}"
|
|
107
107
|
end
|
|
108
108
|
|
data/lib/and_one/middleware.rb
CHANGED
|
@@ -5,6 +5,10 @@ module AndOne
|
|
|
5
5
|
# Designed to NOT interfere with error propagation —
|
|
6
6
|
# if the app raises, we cleanly stop scanning without adding
|
|
7
7
|
# to or corrupting the original backtrace.
|
|
8
|
+
#
|
|
9
|
+
# When `AndOne.dev_toast` is enabled (default in development),
|
|
10
|
+
# detected N+1s are injected as a toast notification into HTML responses
|
|
11
|
+
# with a link to the DevUI dashboard.
|
|
8
12
|
class Middleware
|
|
9
13
|
include ScanHelper
|
|
10
14
|
|
|
@@ -13,7 +17,41 @@ module AndOne
|
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
def call(env)
|
|
16
|
-
|
|
20
|
+
return @app.call(env) if !AndOne.enabled? || AndOne.scanning?
|
|
21
|
+
|
|
22
|
+
begin
|
|
23
|
+
AndOne.scan
|
|
24
|
+
status, headers, body = @app.call(env)
|
|
25
|
+
detections = AndOne.finish
|
|
26
|
+
|
|
27
|
+
if AndOne.dev_toast && detections&.any? && html_response?(headers) && status == 200
|
|
28
|
+
body = inject_toast(body, detections)
|
|
29
|
+
# Recalculate Content-Length since we modified the body
|
|
30
|
+
headers.delete("content-length")
|
|
31
|
+
headers.delete("Content-Length")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
[status, headers, body]
|
|
35
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
36
|
+
and_one_quietly_stop
|
|
37
|
+
raise
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def html_response?(headers)
|
|
44
|
+
content_type = headers["content-type"] || headers["Content-Type"]
|
|
45
|
+
content_type&.include?("text/html")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def inject_toast(body, detections)
|
|
49
|
+
full_body = +""
|
|
50
|
+
body.each { |chunk| full_body << chunk }
|
|
51
|
+
body.close if body.respond_to?(:close)
|
|
52
|
+
|
|
53
|
+
injected = DevToast.inject(full_body, detections)
|
|
54
|
+
[injected]
|
|
17
55
|
end
|
|
18
56
|
end
|
|
19
57
|
end
|
data/lib/and_one/railtie.rb
CHANGED
|
@@ -13,9 +13,12 @@ module AndOne
|
|
|
13
13
|
# Rack middleware for web requests
|
|
14
14
|
app.middleware.insert_before(0, AndOne::Middleware)
|
|
15
15
|
|
|
16
|
-
# Dev UI dashboard for N+1 overview (requires aggregate_mode)
|
|
17
16
|
if Rails.env.development?
|
|
17
|
+
# Dev UI dashboard for N+1 overview
|
|
18
18
|
app.middleware.use(AndOne::DevUI)
|
|
19
|
+
|
|
20
|
+
# Dev toast: show in-page N+1 notifications (default on in development)
|
|
21
|
+
AndOne.dev_toast = true if AndOne.dev_toast.nil?
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
# ActiveJob hook — covers all job backends (Sidekiq, GoodJob, SolidQueue, etc.)
|
data/lib/and_one/rspec.rb
CHANGED
data/lib/and_one/scan_helper.rb
CHANGED
|
@@ -15,7 +15,7 @@ module AndOne
|
|
|
15
15
|
result = yield
|
|
16
16
|
AndOne.finish
|
|
17
17
|
result
|
|
18
|
-
rescue Exception
|
|
18
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
19
19
|
and_one_quietly_stop
|
|
20
20
|
raise
|
|
21
21
|
end
|
|
@@ -25,7 +25,7 @@ module AndOne
|
|
|
25
25
|
Thread.current[:and_one_detector]&.send(:unsubscribe)
|
|
26
26
|
Thread.current[:and_one_detector] = nil
|
|
27
27
|
Thread.current[:and_one_paused] = false
|
|
28
|
-
rescue
|
|
28
|
+
rescue StandardError
|
|
29
29
|
nil
|
|
30
30
|
end
|
|
31
31
|
end
|
data/lib/and_one/version.rb
CHANGED
data/lib/and_one.rb
CHANGED
|
@@ -14,8 +14,9 @@ module AndOne
|
|
|
14
14
|
class << self
|
|
15
15
|
attr_accessor :enabled, :raise_on_detect, :backtrace_cleaner,
|
|
16
16
|
:allow_stack_paths, :ignore_queries, :ignore_callers,
|
|
17
|
-
:min_n_queries, :notifications_callback,
|
|
18
|
-
:ignore_file_path, :json_logging, :env_thresholds
|
|
17
|
+
:min_n_queries, :notifications_callback,
|
|
18
|
+
:ignore_file_path, :json_logging, :env_thresholds,
|
|
19
|
+
:dev_toast
|
|
19
20
|
|
|
20
21
|
def configure
|
|
21
22
|
yield self
|
|
@@ -33,19 +34,19 @@ module AndOne
|
|
|
33
34
|
|
|
34
35
|
start_scan
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
37
|
+
return unless block_given?
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
yield
|
|
41
|
+
detections = detector.finish
|
|
42
|
+
stop_scan
|
|
43
|
+
report(detections) if detections.any?
|
|
44
|
+
detections
|
|
45
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
46
|
+
# On error, clean up without reporting — don't add noise to real errors
|
|
47
|
+
detector&.send(:unsubscribe)
|
|
48
|
+
stop_scan
|
|
49
|
+
raise
|
|
49
50
|
end
|
|
50
51
|
end
|
|
51
52
|
|
|
@@ -133,7 +134,7 @@ module AndOne
|
|
|
133
134
|
if defined?(Rails) && Rails.respond_to?(:env)
|
|
134
135
|
Rails.env.to_s
|
|
135
136
|
else
|
|
136
|
-
ENV["RAILS_ENV"] || ENV
|
|
137
|
+
ENV["RAILS_ENV"] || ENV.fetch("RACK_ENV", nil)
|
|
137
138
|
end
|
|
138
139
|
end
|
|
139
140
|
|
|
@@ -150,7 +151,7 @@ module AndOne
|
|
|
150
151
|
Thread.current
|
|
151
152
|
end
|
|
152
153
|
|
|
153
|
-
def report(detections)
|
|
154
|
+
def report(detections) # rubocop:disable Metrics
|
|
154
155
|
# Filter out ignored detections
|
|
155
156
|
detections = detections.reject do |d|
|
|
156
157
|
ignore_list.ignored?(d, d.raw_caller_strings) ||
|
|
@@ -159,11 +160,9 @@ module AndOne
|
|
|
159
160
|
|
|
160
161
|
return if detections.empty?
|
|
161
162
|
|
|
162
|
-
#
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return if detections.empty?
|
|
166
|
-
end
|
|
163
|
+
# Record to aggregate and only report NEW unique detections
|
|
164
|
+
detections = detections.select { |d| aggregate.record(d) }
|
|
165
|
+
return if detections.empty?
|
|
167
166
|
|
|
168
167
|
cleaner = backtrace_cleaner || default_backtrace_cleaner
|
|
169
168
|
|
|
@@ -181,7 +180,7 @@ module AndOne
|
|
|
181
180
|
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
182
181
|
Rails.logger.warn(json_output)
|
|
183
182
|
else
|
|
184
|
-
|
|
183
|
+
warn(json_output)
|
|
185
184
|
end
|
|
186
185
|
end
|
|
187
186
|
|
|
@@ -191,7 +190,7 @@ module AndOne
|
|
|
191
190
|
if ENV["GITHUB_ACTIONS"]
|
|
192
191
|
detections.each do |d|
|
|
193
192
|
file, line = parse_frame_location(d.fix_location || d.origin_frame)
|
|
194
|
-
query_count = "#{d.count} queries to `#{d.table_name ||
|
|
193
|
+
query_count = "#{d.count} queries to `#{d.table_name || "unknown"}`"
|
|
195
194
|
if file
|
|
196
195
|
$stdout.puts "::warning file=#{file},line=#{line || 1}::N+1 detected: #{query_count}. Add `.includes(:#{suggest_association_name(d)})` to fix."
|
|
197
196
|
else
|
|
@@ -200,15 +199,11 @@ module AndOne
|
|
|
200
199
|
end
|
|
201
200
|
end
|
|
202
201
|
|
|
203
|
-
if raise_on_detect
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
Rails.logger.warn("\n#{message}")
|
|
209
|
-
end
|
|
210
|
-
$stderr.puts("\n#{message}") if $stderr.tty?
|
|
211
|
-
end
|
|
202
|
+
raise NPlus1Error, "\n#{message}" if raise_on_detect
|
|
203
|
+
|
|
204
|
+
unless json_logging
|
|
205
|
+
Rails.logger.warn("\n#{message}") if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
206
|
+
warn("\n#{message}") if $stderr.tty?
|
|
212
207
|
end
|
|
213
208
|
end
|
|
214
209
|
end
|
|
@@ -222,7 +217,7 @@ module AndOne
|
|
|
222
217
|
return false unless patterns&.any?
|
|
223
218
|
|
|
224
219
|
raw_caller_strings.any? do |frame|
|
|
225
|
-
patterns.any? { |pattern| pattern
|
|
220
|
+
patterns.any? { |pattern| pattern.match?(frame) }
|
|
226
221
|
end
|
|
227
222
|
end
|
|
228
223
|
|
|
@@ -231,20 +226,24 @@ module AndOne
|
|
|
231
226
|
|
|
232
227
|
# Extract file:line from a backtrace frame like "app/controllers/posts_controller.rb:15:in `index'"
|
|
233
228
|
clean = frame
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
229
|
+
.sub(%r{.*/app/}, "app/")
|
|
230
|
+
.sub(%r{.*/lib/}, "lib/")
|
|
231
|
+
.sub(%r{.*/test/}, "test/")
|
|
232
|
+
.sub(%r{.*/spec/}, "spec/")
|
|
238
233
|
|
|
239
234
|
if clean =~ /\A(.+?):(\d+)/
|
|
240
|
-
[
|
|
235
|
+
[::Regexp.last_match(1), ::Regexp.last_match(2).to_i]
|
|
241
236
|
else
|
|
242
237
|
[clean, nil]
|
|
243
238
|
end
|
|
244
239
|
end
|
|
245
240
|
|
|
246
241
|
def suggest_association_name(detection)
|
|
247
|
-
suggestion =
|
|
242
|
+
suggestion = begin
|
|
243
|
+
AssociationResolver.resolve(detection, detection.raw_caller_strings)
|
|
244
|
+
rescue StandardError
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
248
247
|
suggestion&.association_name || detection.table_name || "association"
|
|
249
248
|
end
|
|
250
249
|
|
|
@@ -271,6 +270,7 @@ require_relative "and_one/aggregate"
|
|
|
271
270
|
require_relative "and_one/matchers"
|
|
272
271
|
require_relative "and_one/scan_helper"
|
|
273
272
|
require_relative "and_one/dev_ui"
|
|
273
|
+
require_relative "and_one/dev_toast"
|
|
274
274
|
require_relative "and_one/console"
|
|
275
275
|
require_relative "and_one/middleware"
|
|
276
276
|
require_relative "and_one/active_job_hook"
|