and_one 0.1.0 → 0.2.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 +8 -0
- data/README.md +34 -0
- data/lib/and_one/active_job_hook.rb +2 -2
- data/lib/and_one/aggregate.rb +5 -5
- 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 +48 -47
- 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 +36 -34
- metadata +7 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f74cbffeb954bdf743f6028d005244aa5d4f76c229f7543145d3e77f2b468398
|
|
4
|
+
data.tar.gz: 418273820bcdb2b8a86a3d8b76922ba97b0540bd48b6a6ac5c3e3317fe3ac703
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 159e5753a847aa025f9168c9962cdb47d53489ccd0b81272b81039f6784be1cd2b3ecc9ec7537a19245b030602f539e7578d52d05dcdd28abcc45101a47d2a44
|
|
7
|
+
data.tar.gz: 9885b04b8ed1b4aded500bb673ac5f4526ea834b96c3beee407a7e3e165fa2a3eea9100edd5d67c4d0c6a78cde3940557b77a2609f283249afd1d6ce7f73b936
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
# Get staged Ruby files
|
|
5
|
+
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.rb$' || true)
|
|
6
|
+
|
|
7
|
+
if [ -n "$STAGED_FILES" ]; then
|
|
8
|
+
echo "Running RuboCop on staged files..."
|
|
9
|
+
bundle exec rubocop --force-exclusion $STAGED_FILES
|
|
10
|
+
fi
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-03-02
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Dev toast notifications** — When an N+1 is detected during a request, a toast notification is injected into the bottom-right corner of the page showing which tables were affected with a link to the `/__and_one` dashboard. Enabled by default in development. Auto-dismisses after 8 seconds; hover to keep open. Only appears on HTML 200 responses. Disable with `AndOne.dev_toast = false`.
|
|
8
|
+
- New `dev_toast` configuration option
|
|
9
|
+
- "Development UI" section in README documenting both the toast and dashboard features
|
|
10
|
+
|
|
3
11
|
## [0.1.0] - 2026-02-27
|
|
4
12
|
|
|
5
13
|
- Initial release
|
data/README.md
CHANGED
|
@@ -16,6 +16,7 @@ AndOne stays completely invisible until it detects an N+1 query — then it tell
|
|
|
16
16
|
- **Ignore file** — `.and_one_ignore` with `gem:`, `path:`, `query:`, and `fingerprint:` rules
|
|
17
17
|
- **Aggregate mode** — report each unique N+1 once per server session with occurrence counts
|
|
18
18
|
- **Test matchers** — Minitest (`assert_no_n_plus_one`) and RSpec (`expect { }.not_to cause_n_plus_one`)
|
|
19
|
+
- **Dev toast notifications** — in-page toast on every page that triggers an N+1, with a link to the dashboard
|
|
19
20
|
- **Dev UI dashboard** — browse `/__and_one` in development for a live N+1 overview
|
|
20
21
|
- **Rails console integration** — auto-scans in `rails console` and prints warnings inline
|
|
21
22
|
- **Structured JSON logging** — JSON output mode for Datadog, Splunk, and other log aggregation services
|
|
@@ -148,6 +149,36 @@ AndOne.aggregate.size # number of unique patterns
|
|
|
148
149
|
AndOne.aggregate.reset! # clear and start fresh
|
|
149
150
|
```
|
|
150
151
|
|
|
152
|
+
## Development UI
|
|
153
|
+
|
|
154
|
+
### In-page toast notifications
|
|
155
|
+
|
|
156
|
+
When an N+1 is detected during a request, AndOne injects a small toast notification into the bottom-right corner of the page. The toast shows which tables were affected and links to the full dashboard for details.
|
|
157
|
+
|
|
158
|
+
This is enabled by default in development — no configuration needed. The toast auto-dismisses after 8 seconds, but hovering over it keeps it open.
|
|
159
|
+
|
|
160
|
+
To disable it:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
# config/initializers/and_one.rb
|
|
164
|
+
AndOne.dev_toast = false
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The toast only appears on HTML responses with a 200 status, so it won't interfere with API endpoints, redirects, or error pages.
|
|
168
|
+
|
|
169
|
+
### Dashboard
|
|
170
|
+
|
|
171
|
+
Browse `/__and_one` in development for a full overview of every unique N+1 detected in the current server session. The dashboard shows the query, origin, fix location, and suggested `.includes()` call for each detection.
|
|
172
|
+
|
|
173
|
+
The dashboard requires aggregate mode to track detections across requests:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
# config/initializers/and_one.rb
|
|
177
|
+
AndOne.aggregate_mode = true
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Both features work together: the toast gives you immediate feedback on the page you're looking at, and the dashboard link takes you to the full picture.
|
|
181
|
+
|
|
151
182
|
## Test Matchers
|
|
152
183
|
|
|
153
184
|
### Minitest
|
|
@@ -217,6 +248,9 @@ AndOne.configure do |config|
|
|
|
217
248
|
# Aggregate mode — only report each unique N+1 once per session
|
|
218
249
|
config.aggregate_mode = true
|
|
219
250
|
|
|
251
|
+
# In-page toast notifications (default: true in development)
|
|
252
|
+
config.dev_toast = true
|
|
253
|
+
|
|
220
254
|
# Path to ignore file (default: Rails.root/.and_one_ignore)
|
|
221
255
|
config.ignore_file_path = Rails.root.join(".and_one_ignore").to_s
|
|
222
256
|
|
data/lib/and_one/aggregate.rb
CHANGED
|
@@ -14,7 +14,7 @@ module AndOne
|
|
|
14
14
|
# AndOne.aggregate.reset!
|
|
15
15
|
#
|
|
16
16
|
class Aggregate
|
|
17
|
-
Entry = Struct.new(:detection, :occurrences, :first_seen_at, :last_seen_at
|
|
17
|
+
Entry = Struct.new(:detection, :occurrences, :first_seen_at, :last_seen_at)
|
|
18
18
|
|
|
19
19
|
def initialize
|
|
20
20
|
@mutex = Mutex.new
|
|
@@ -65,19 +65,19 @@ module AndOne
|
|
|
65
65
|
|
|
66
66
|
lines = []
|
|
67
67
|
lines << ""
|
|
68
|
-
lines << "🏀 AndOne Session Summary: #{@entries.size} unique N+1 pattern#{
|
|
69
|
-
lines << "─" * 60
|
|
68
|
+
lines << "🏀 AndOne Session Summary: #{@entries.size} unique N+1 pattern#{"s" if @entries.size != 1}"
|
|
69
|
+
lines << ("─" * 60)
|
|
70
70
|
|
|
71
71
|
@entries.each_with_index do |(fp, entry), i|
|
|
72
72
|
det = entry.detection
|
|
73
|
-
lines << " #{i + 1}) #{det.table_name ||
|
|
73
|
+
lines << " #{i + 1}) #{det.table_name || "unknown"} — #{entry.occurrences} occurrence#{"s" if entry.occurrences != 1}"
|
|
74
74
|
lines << " #{det.sample_query[0, 120]}"
|
|
75
75
|
lines << " origin: #{det.origin_frame}" if det.origin_frame
|
|
76
76
|
lines << " fingerprint: #{fp}"
|
|
77
77
|
lines << ""
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
-
lines << "─" * 60
|
|
80
|
+
lines << ("─" * 60)
|
|
81
81
|
lines.join("\n")
|
|
82
82
|
end
|
|
83
83
|
end
|
|
@@ -50,7 +50,7 @@ module AndOne
|
|
|
50
50
|
|
|
51
51
|
model = ActiveRecord::Base.descendants.detect do |klass|
|
|
52
52
|
klass.table_name == table_name
|
|
53
|
-
rescue
|
|
53
|
+
rescue StandardError
|
|
54
54
|
false
|
|
55
55
|
end
|
|
56
56
|
|
|
@@ -82,11 +82,11 @@ module AndOne
|
|
|
82
82
|
|
|
83
83
|
klass.reflect_on_all_associations.each do |assoc|
|
|
84
84
|
matched = if effective_key
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
association_matches?(assoc, target_model, effective_key)
|
|
86
|
+
else
|
|
87
|
+
# For through associations, foreign key may not be directly visible
|
|
88
|
+
through_association_matches?(assoc, target_model)
|
|
89
|
+
end
|
|
90
90
|
|
|
91
91
|
next unless matched
|
|
92
92
|
|
|
@@ -98,10 +98,10 @@ module AndOne
|
|
|
98
98
|
fix_hint: build_fix_hint(klass, assoc.name),
|
|
99
99
|
loading_strategy: strategy,
|
|
100
100
|
is_through: assoc.is_a?(ActiveRecord::Reflection::ThroughReflection),
|
|
101
|
-
is_polymorphic: assoc.respond_to?(:options) &&
|
|
101
|
+
is_polymorphic: assoc.respond_to?(:options) && !assoc.options[:as].nil?
|
|
102
102
|
}
|
|
103
103
|
end
|
|
104
|
-
rescue
|
|
104
|
+
rescue StandardError
|
|
105
105
|
next
|
|
106
106
|
end
|
|
107
107
|
|
|
@@ -133,27 +133,25 @@ module AndOne
|
|
|
133
133
|
end
|
|
134
134
|
|
|
135
135
|
def association_matches?(assoc, target_model, foreign_key)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
assoc.klass == target_model && poly_fk == foreign_key
|
|
148
|
-
else
|
|
149
|
-
assoc.klass == target_model && assoc.foreign_key.to_s == foreign_key
|
|
150
|
-
end
|
|
136
|
+
case assoc
|
|
137
|
+
when ActiveRecord::Reflection::ThroughReflection
|
|
138
|
+
# has_many :through — check if the source association points to our target
|
|
139
|
+
assoc.klass == target_model
|
|
140
|
+
when ActiveRecord::Reflection::HasManyReflection,
|
|
141
|
+
ActiveRecord::Reflection::HasOneReflection
|
|
142
|
+
if assoc.options[:as]
|
|
143
|
+
# Polymorphic: has_many :comments, as: :commentable
|
|
144
|
+
# The foreign key is like "commentable_id" and there's a "commentable_type" column
|
|
145
|
+
poly_fk = "#{assoc.options[:as]}_id"
|
|
146
|
+
assoc.klass == target_model && poly_fk == foreign_key
|
|
151
147
|
else
|
|
152
|
-
|
|
148
|
+
assoc.klass == target_model && assoc.foreign_key.to_s == foreign_key
|
|
153
149
|
end
|
|
154
|
-
|
|
150
|
+
else
|
|
155
151
|
false
|
|
156
152
|
end
|
|
153
|
+
rescue NameError
|
|
154
|
+
false
|
|
157
155
|
end
|
|
158
156
|
|
|
159
157
|
def build_fix_hint(parent_model, association_name)
|
|
@@ -161,13 +159,10 @@ module AndOne
|
|
|
161
159
|
end
|
|
162
160
|
|
|
163
161
|
# Determine the optimal loading strategy based on query patterns
|
|
164
|
-
def loading_strategy(sql,
|
|
162
|
+
def loading_strategy(sql, _association_name)
|
|
165
163
|
# If the query has WHERE conditions on the association table, eager_load
|
|
166
164
|
# is better because it does a LEFT OUTER JOIN allowing WHERE filtering
|
|
167
|
-
if sql =~ /\bWHERE\b/i && sql =~ /\bJOIN\b/i
|
|
168
|
-
:eager_load
|
|
169
|
-
elsif sql =~ /\bWHERE\b.*\b(?:AND|OR)\b/i
|
|
170
|
-
# Complex WHERE — eager_load with JOIN is more efficient
|
|
165
|
+
if sql =~ /\bWHERE\b/i && (sql =~ /\bJOIN\b/i || sql =~ /\b(?:AND|OR)\b/i)
|
|
171
166
|
:eager_load
|
|
172
167
|
else
|
|
173
168
|
# Default: preload is generally faster (separate queries, no JOIN overhead)
|
|
@@ -202,10 +197,10 @@ module AndOne
|
|
|
202
197
|
return nil unless actionable? && @parent_model
|
|
203
198
|
|
|
204
199
|
assoc_type = if @is_through
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
200
|
+
"has_many :#{@association_name}, through: ..."
|
|
201
|
+
else
|
|
202
|
+
"has_many :#{@association_name}"
|
|
203
|
+
end
|
|
209
204
|
|
|
210
205
|
"Or prevent at the model level: `#{assoc_type}, strict_loading: true` in #{@parent_model.name}"
|
|
211
206
|
end
|
|
@@ -219,8 +214,6 @@ module AndOne
|
|
|
219
214
|
"Consider `.eager_load(:#{@association_name})` instead — your query filters on the association, so a JOIN is more efficient"
|
|
220
215
|
when :preload
|
|
221
216
|
"Consider `.preload(:#{@association_name})` — separate queries avoid JOIN overhead for simple loading"
|
|
222
|
-
else
|
|
223
|
-
nil # :includes is the default, no extra hint needed
|
|
224
217
|
end
|
|
225
218
|
end
|
|
226
219
|
end
|
data/lib/and_one/console.rb
CHANGED
|
@@ -49,7 +49,7 @@ module AndOne
|
|
|
49
49
|
|
|
50
50
|
def finish_scan
|
|
51
51
|
AndOne.finish if AndOne.scanning?
|
|
52
|
-
rescue
|
|
52
|
+
rescue StandardError
|
|
53
53
|
# Don't let cleanup errors interrupt the console
|
|
54
54
|
nil
|
|
55
55
|
end
|
|
@@ -79,9 +79,9 @@ module AndOne
|
|
|
79
79
|
|
|
80
80
|
# IRB in Rails 7.1+ uses IRB::Context#evaluate with hooks
|
|
81
81
|
# We hook into the SIGINT-safe eval output via an around_eval approach
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
return unless defined?(::IRB::Context)
|
|
83
|
+
|
|
84
|
+
::IRB::Context.prepend(IrbContextPatch)
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
def remove_irb_hook
|
|
@@ -94,15 +94,13 @@ module AndOne
|
|
|
94
94
|
|
|
95
95
|
@pry_hook_installed = true
|
|
96
96
|
|
|
97
|
-
::Pry.hooks.add_hook(:after_eval, :and_one_console) do |
|
|
97
|
+
::Pry.hooks.add_hook(:after_eval, :and_one_console) do |_result, _pry|
|
|
98
98
|
AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
|
|
99
99
|
end
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
def remove_pry_hook
|
|
103
|
-
if defined?(::Pry) && ::Pry.hooks
|
|
104
|
-
::Pry.hooks.delete_hook(:after_eval, :and_one_console)
|
|
105
|
-
end
|
|
103
|
+
::Pry.hooks.delete_hook(:after_eval, :and_one_console) if defined?(::Pry) && ::Pry.hooks
|
|
106
104
|
@pry_hook_installed = false
|
|
107
105
|
end
|
|
108
106
|
|
|
@@ -119,7 +117,7 @@ module AndOne
|
|
|
119
117
|
result = super
|
|
120
118
|
AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
|
|
121
119
|
result
|
|
122
|
-
rescue
|
|
120
|
+
rescue StandardError
|
|
123
121
|
AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
|
|
124
122
|
raise
|
|
125
123
|
end
|
data/lib/and_one/detection.rb
CHANGED
data/lib/and_one/detector.rb
CHANGED
|
@@ -5,8 +5,8 @@ module AndOne
|
|
|
5
5
|
# Each instance tracks queries for a single request/block scope.
|
|
6
6
|
class Detector
|
|
7
7
|
DEFAULT_ALLOW_LIST = [
|
|
8
|
-
/
|
|
9
|
-
/
|
|
8
|
+
%r{active_record/relation.*preload_associations},
|
|
9
|
+
%r{active_record/validations/uniqueness}
|
|
10
10
|
].freeze
|
|
11
11
|
|
|
12
12
|
attr_reader :detections
|
|
@@ -58,7 +58,7 @@ module AndOne
|
|
|
58
58
|
name = payload[:name]
|
|
59
59
|
|
|
60
60
|
next if name == "SCHEMA"
|
|
61
|
-
next
|
|
61
|
+
next unless sql.include?("SELECT")
|
|
62
62
|
next if payload[:cached]
|
|
63
63
|
next if current_detector.send(:ignored?, sql)
|
|
64
64
|
|
|
@@ -79,13 +79,13 @@ module AndOne
|
|
|
79
79
|
@query_holder[location_key] << sql
|
|
80
80
|
|
|
81
81
|
# Only store caller on the second occurrence to save memory
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
82
|
+
return unless @query_counter[location_key] >= 2
|
|
83
|
+
|
|
84
|
+
@query_callers[location_key] = locations
|
|
85
|
+
@query_metadata[location_key] ||= {
|
|
86
|
+
connection_adapter: adapter_name,
|
|
87
|
+
type_casted_binds: payload[:type_casted_binds]
|
|
88
|
+
}
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
def location_fingerprint(locations)
|
|
@@ -99,7 +99,7 @@ module AndOne
|
|
|
99
99
|
|
|
100
100
|
def adapter_name
|
|
101
101
|
ActiveRecord::Base.connection_db_config.adapter
|
|
102
|
-
rescue
|
|
102
|
+
rescue StandardError
|
|
103
103
|
"unknown"
|
|
104
104
|
end
|
|
105
105
|
|
|
@@ -135,7 +135,7 @@ module AndOne
|
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
def ignored?(sql)
|
|
138
|
-
@ignore_queries.any? { |pattern| pattern
|
|
138
|
+
@ignore_queries.any? { |pattern| pattern.match?(sql) }
|
|
139
139
|
end
|
|
140
140
|
end
|
|
141
141
|
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AndOne
|
|
4
|
+
# Injects a small toast notification into HTML responses when N+1 queries
|
|
5
|
+
# are detected during the request. Links to the DevUI dashboard for details.
|
|
6
|
+
#
|
|
7
|
+
# Activated automatically by the Railtie in development when `dev_toast` is on,
|
|
8
|
+
# or can be enabled manually:
|
|
9
|
+
# AndOne.dev_toast = true
|
|
10
|
+
#
|
|
11
|
+
# The toast appears as a fixed-position badge in the bottom-right corner
|
|
12
|
+
# and auto-dismisses after 8 seconds (click to keep open).
|
|
13
|
+
module DevToast
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Injects toast HTML/JS/CSS before </body> in an HTML response.
|
|
17
|
+
# Returns the modified body string, or the original if not injectable.
|
|
18
|
+
def inject(body_string, detections)
|
|
19
|
+
return body_string if detections.nil? || detections.empty?
|
|
20
|
+
return body_string unless body_string.include?("</body>")
|
|
21
|
+
|
|
22
|
+
toast_html = render_toast(detections)
|
|
23
|
+
body_string.sub("</body>", "#{toast_html}\n</body>")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def render_toast(detections)
|
|
27
|
+
count = detections.size
|
|
28
|
+
label = "N+1 quer#{count == 1 ? "y" : "ies"}"
|
|
29
|
+
|
|
30
|
+
summaries = detections.map do |d|
|
|
31
|
+
table = escape(d.table_name || "unknown")
|
|
32
|
+
"#{d.count}x <code>#{table}</code>"
|
|
33
|
+
end.first(5)
|
|
34
|
+
|
|
35
|
+
extra = count > 5 ? "<div class=\"and-one-toast-extra\">...and #{count - 5} more</div>" : ""
|
|
36
|
+
|
|
37
|
+
<<~HTML
|
|
38
|
+
<div id="and-one-toast" class="and-one-toast" role="status" aria-live="polite">
|
|
39
|
+
<div class="and-one-toast-header">
|
|
40
|
+
<span class="and-one-toast-icon">🏀</span>
|
|
41
|
+
<strong>AndOne:</strong> #{count} #{label} detected
|
|
42
|
+
<button class="and-one-toast-close" onclick="document.getElementById('and-one-toast').remove()" aria-label="Dismiss">×</button>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="and-one-toast-body">
|
|
45
|
+
#{summaries.join("<br>")}
|
|
46
|
+
#{extra}
|
|
47
|
+
</div>
|
|
48
|
+
<a class="and-one-toast-link" href="#{DevUI::MOUNT_PATH}">View Dashboard →</a>
|
|
49
|
+
</div>
|
|
50
|
+
<style>
|
|
51
|
+
.and-one-toast {
|
|
52
|
+
position: fixed;
|
|
53
|
+
bottom: 1rem;
|
|
54
|
+
right: 1rem;
|
|
55
|
+
z-index: 999999;
|
|
56
|
+
background: #1a1a2e;
|
|
57
|
+
color: #e0e0e0;
|
|
58
|
+
border: 2px solid #ff6b6b;
|
|
59
|
+
border-radius: 8px;
|
|
60
|
+
padding: 0;
|
|
61
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
|
|
62
|
+
font-size: 13px;
|
|
63
|
+
max-width: 380px;
|
|
64
|
+
min-width: 260px;
|
|
65
|
+
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
|
|
66
|
+
animation: and-one-slide-in 0.3s ease-out;
|
|
67
|
+
transition: opacity 0.3s ease;
|
|
68
|
+
}
|
|
69
|
+
.and-one-toast-header {
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 0.4rem;
|
|
73
|
+
padding: 0.5rem 0.75rem;
|
|
74
|
+
background: #16213e;
|
|
75
|
+
border-radius: 6px 6px 0 0;
|
|
76
|
+
border-bottom: 1px solid #2a2a4a;
|
|
77
|
+
color: #ff6b6b;
|
|
78
|
+
font-size: 13px;
|
|
79
|
+
}
|
|
80
|
+
.and-one-toast-icon { font-size: 16px; }
|
|
81
|
+
.and-one-toast-close {
|
|
82
|
+
margin-left: auto;
|
|
83
|
+
background: none;
|
|
84
|
+
border: none;
|
|
85
|
+
color: #888;
|
|
86
|
+
font-size: 18px;
|
|
87
|
+
cursor: pointer;
|
|
88
|
+
padding: 0 0.25rem;
|
|
89
|
+
line-height: 1;
|
|
90
|
+
}
|
|
91
|
+
.and-one-toast-close:hover { color: #ff6b6b; }
|
|
92
|
+
.and-one-toast-body {
|
|
93
|
+
padding: 0.5rem 0.75rem;
|
|
94
|
+
line-height: 1.5;
|
|
95
|
+
color: #ccc;
|
|
96
|
+
}
|
|
97
|
+
.and-one-toast-body code {
|
|
98
|
+
background: #16213e;
|
|
99
|
+
padding: 0.1rem 0.3rem;
|
|
100
|
+
border-radius: 3px;
|
|
101
|
+
font-size: 12px;
|
|
102
|
+
color: #ffd93d;
|
|
103
|
+
}
|
|
104
|
+
.and-one-toast-extra {
|
|
105
|
+
color: #888;
|
|
106
|
+
font-size: 12px;
|
|
107
|
+
margin-top: 0.25rem;
|
|
108
|
+
}
|
|
109
|
+
.and-one-toast-link {
|
|
110
|
+
display: block;
|
|
111
|
+
padding: 0.5rem 0.75rem;
|
|
112
|
+
color: #a8d8ea;
|
|
113
|
+
text-decoration: none;
|
|
114
|
+
font-size: 12px;
|
|
115
|
+
border-top: 1px solid #2a2a4a;
|
|
116
|
+
}
|
|
117
|
+
.and-one-toast-link:hover {
|
|
118
|
+
background: #16213e;
|
|
119
|
+
border-radius: 0 0 6px 6px;
|
|
120
|
+
text-decoration: underline;
|
|
121
|
+
}
|
|
122
|
+
@keyframes and-one-slide-in {
|
|
123
|
+
from { transform: translateY(1rem); opacity: 0; }
|
|
124
|
+
to { transform: translateY(0); opacity: 1; }
|
|
125
|
+
}
|
|
126
|
+
</style>
|
|
127
|
+
<script>
|
|
128
|
+
(function() {
|
|
129
|
+
var toast = document.getElementById('and-one-toast');
|
|
130
|
+
if (!toast) return;
|
|
131
|
+
var timer = setTimeout(function() {
|
|
132
|
+
toast.style.opacity = '0';
|
|
133
|
+
setTimeout(function() { toast.remove(); }, 300);
|
|
134
|
+
}, 8000);
|
|
135
|
+
toast.addEventListener('mouseenter', function() { clearTimeout(timer); });
|
|
136
|
+
})();
|
|
137
|
+
</script>
|
|
138
|
+
HTML
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def escape(text)
|
|
142
|
+
text.to_s
|
|
143
|
+
.gsub("&", "&")
|
|
144
|
+
.gsub("<", "<")
|
|
145
|
+
.gsub(">", ">")
|
|
146
|
+
.gsub('"', """)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
data/lib/and_one/dev_ui.rb
CHANGED
|
@@ -28,7 +28,7 @@ module AndOne
|
|
|
28
28
|
|
|
29
29
|
private
|
|
30
30
|
|
|
31
|
-
def serve_dashboard(
|
|
31
|
+
def serve_dashboard(_env)
|
|
32
32
|
entries = AndOne.aggregate_mode ? AndOne.aggregate.detections : {}
|
|
33
33
|
|
|
34
34
|
html = render_html(entries)
|
|
@@ -37,49 +37,49 @@ module AndOne
|
|
|
37
37
|
|
|
38
38
|
def render_html(entries)
|
|
39
39
|
rows = if entries.empty?
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
40
|
+
<<~HTML
|
|
41
|
+
<tr>
|
|
42
|
+
<td colspan="6" class="empty">
|
|
43
|
+
No N+1 queries detected yet.
|
|
44
|
+
#{"<br><strong>Tip:</strong> Set <code>AndOne.aggregate_mode = true</code> to collect detections across requests." unless AndOne.aggregate_mode}
|
|
45
|
+
</td>
|
|
46
|
+
</tr>
|
|
47
|
+
HTML
|
|
48
|
+
else
|
|
49
|
+
entries.map.with_index do |(fp, entry), i|
|
|
50
|
+
det = entry.detection
|
|
51
|
+
suggestion = begin
|
|
52
|
+
AndOne::AssociationResolver.resolve(det, det.raw_caller_strings)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
fix = suggestion&.actionable? ? h(suggestion.fix_hint) : "—"
|
|
57
|
+
strict_hint = suggestion&.strict_loading_hint ? h(suggestion.strict_loading_hint) : ""
|
|
58
|
+
loading_hint = suggestion&.loading_strategy_hint ? h(suggestion.loading_strategy_hint) : ""
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
origin = det.origin_frame ? format_frame(det.origin_frame) : "—"
|
|
61
|
+
fix_loc = det.fix_location ? format_frame(det.fix_location) : "—"
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
63
|
+
<<~HTML
|
|
64
|
+
<tr>
|
|
65
|
+
<td>#{i + 1}</td>
|
|
66
|
+
<td><code>#{h(det.table_name || "unknown")}</code></td>
|
|
67
|
+
<td>#{entry.occurrences}</td>
|
|
68
|
+
<td><code class="sql">#{h(truncate(det.sample_query, 200))}</code></td>
|
|
69
|
+
<td>
|
|
70
|
+
<div class="origin">#{h(origin)}</div>
|
|
71
|
+
<div class="fix-loc">⇒ #{h(fix_loc)}</div>
|
|
72
|
+
</td>
|
|
73
|
+
<td>
|
|
74
|
+
<div class="suggestion">#{fix}</div>
|
|
75
|
+
#{"<div class=\"strategy\">#{loading_hint}</div>" unless loading_hint.empty?}
|
|
76
|
+
#{"<div class=\"strict\">#{strict_hint}</div>" unless strict_hint.empty?}
|
|
77
|
+
<div class="fingerprint">#{h(fp)}</div>
|
|
78
|
+
</td>
|
|
79
|
+
</tr>
|
|
80
|
+
HTML
|
|
81
|
+
end.join
|
|
82
|
+
end
|
|
83
83
|
|
|
84
84
|
<<~HTML
|
|
85
85
|
<!DOCTYPE html>
|
|
@@ -114,7 +114,7 @@ module AndOne
|
|
|
114
114
|
</head>
|
|
115
115
|
<body>
|
|
116
116
|
<h1>🏀 AndOne — N+1 Dashboard</h1>
|
|
117
|
-
<p class="subtitle">#{entries.size} unique N+1 pattern#{
|
|
117
|
+
<p class="subtitle">#{entries.size} unique N+1 pattern#{"s" if entries.size != 1} detected this session</p>
|
|
118
118
|
<div class="actions">
|
|
119
119
|
<a href="#{MOUNT_PATH}">↻ Refresh</a>
|
|
120
120
|
</div>
|
|
@@ -140,14 +140,15 @@ module AndOne
|
|
|
140
140
|
|
|
141
141
|
def h(text)
|
|
142
142
|
text.to_s
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
.gsub("&", "&")
|
|
144
|
+
.gsub("<", "<")
|
|
145
|
+
.gsub(">", ">")
|
|
146
|
+
.gsub('"', """)
|
|
147
147
|
end
|
|
148
148
|
|
|
149
149
|
def truncate(text, max)
|
|
150
150
|
return text if text.length <= max
|
|
151
|
+
|
|
151
152
|
"#{text[0...max]}..."
|
|
152
153
|
end
|
|
153
154
|
|
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 (requires aggregate_mode)
|
|
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
|
@@ -15,7 +15,8 @@ module AndOne
|
|
|
15
15
|
attr_accessor :enabled, :raise_on_detect, :backtrace_cleaner,
|
|
16
16
|
:allow_stack_paths, :ignore_queries, :ignore_callers,
|
|
17
17
|
:min_n_queries, :notifications_callback, :aggregate_mode,
|
|
18
|
-
:ignore_file_path, :json_logging, :env_thresholds
|
|
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) ||
|
|
@@ -181,7 +182,7 @@ module AndOne
|
|
|
181
182
|
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
182
183
|
Rails.logger.warn(json_output)
|
|
183
184
|
else
|
|
184
|
-
|
|
185
|
+
warn(json_output)
|
|
185
186
|
end
|
|
186
187
|
end
|
|
187
188
|
|
|
@@ -191,7 +192,7 @@ module AndOne
|
|
|
191
192
|
if ENV["GITHUB_ACTIONS"]
|
|
192
193
|
detections.each do |d|
|
|
193
194
|
file, line = parse_frame_location(d.fix_location || d.origin_frame)
|
|
194
|
-
query_count = "#{d.count} queries to `#{d.table_name ||
|
|
195
|
+
query_count = "#{d.count} queries to `#{d.table_name || "unknown"}`"
|
|
195
196
|
if file
|
|
196
197
|
$stdout.puts "::warning file=#{file},line=#{line || 1}::N+1 detected: #{query_count}. Add `.includes(:#{suggest_association_name(d)})` to fix."
|
|
197
198
|
else
|
|
@@ -200,15 +201,11 @@ module AndOne
|
|
|
200
201
|
end
|
|
201
202
|
end
|
|
202
203
|
|
|
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
|
|
204
|
+
raise NPlus1Error, "\n#{message}" if raise_on_detect
|
|
205
|
+
|
|
206
|
+
unless json_logging
|
|
207
|
+
Rails.logger.warn("\n#{message}") if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
208
|
+
warn("\n#{message}") if $stderr.tty?
|
|
212
209
|
end
|
|
213
210
|
end
|
|
214
211
|
end
|
|
@@ -222,7 +219,7 @@ module AndOne
|
|
|
222
219
|
return false unless patterns&.any?
|
|
223
220
|
|
|
224
221
|
raw_caller_strings.any? do |frame|
|
|
225
|
-
patterns.any? { |pattern| pattern
|
|
222
|
+
patterns.any? { |pattern| pattern.match?(frame) }
|
|
226
223
|
end
|
|
227
224
|
end
|
|
228
225
|
|
|
@@ -231,20 +228,24 @@ module AndOne
|
|
|
231
228
|
|
|
232
229
|
# Extract file:line from a backtrace frame like "app/controllers/posts_controller.rb:15:in `index'"
|
|
233
230
|
clean = frame
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
231
|
+
.sub(%r{.*/app/}, "app/")
|
|
232
|
+
.sub(%r{.*/lib/}, "lib/")
|
|
233
|
+
.sub(%r{.*/test/}, "test/")
|
|
234
|
+
.sub(%r{.*/spec/}, "spec/")
|
|
238
235
|
|
|
239
236
|
if clean =~ /\A(.+?):(\d+)/
|
|
240
|
-
[
|
|
237
|
+
[::Regexp.last_match(1), ::Regexp.last_match(2).to_i]
|
|
241
238
|
else
|
|
242
239
|
[clean, nil]
|
|
243
240
|
end
|
|
244
241
|
end
|
|
245
242
|
|
|
246
243
|
def suggest_association_name(detection)
|
|
247
|
-
suggestion =
|
|
244
|
+
suggestion = begin
|
|
245
|
+
AssociationResolver.resolve(detection, detection.raw_caller_strings)
|
|
246
|
+
rescue StandardError
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
248
249
|
suggestion&.association_name || detection.table_name || "association"
|
|
249
250
|
end
|
|
250
251
|
|
|
@@ -271,6 +272,7 @@ require_relative "and_one/aggregate"
|
|
|
271
272
|
require_relative "and_one/matchers"
|
|
272
273
|
require_relative "and_one/scan_helper"
|
|
273
274
|
require_relative "and_one/dev_ui"
|
|
275
|
+
require_relative "and_one/dev_toast"
|
|
274
276
|
require_relative "and_one/console"
|
|
275
277
|
require_relative "and_one/middleware"
|
|
276
278
|
require_relative "and_one/active_job_hook"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: and_one
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Keith Thompson
|
|
@@ -10,7 +10,7 @@ cert_chain: []
|
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
|
-
name:
|
|
13
|
+
name: activerecord
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
@@ -24,7 +24,7 @@ dependencies:
|
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '7.0'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
|
-
name:
|
|
27
|
+
name: activesupport
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
@@ -38,7 +38,7 @@ dependencies:
|
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '7.0'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
|
-
name:
|
|
41
|
+
name: railties
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
43
43
|
requirements:
|
|
44
44
|
- - ">="
|
|
@@ -60,6 +60,7 @@ executables: []
|
|
|
60
60
|
extensions: []
|
|
61
61
|
extra_rdoc_files: []
|
|
62
62
|
files:
|
|
63
|
+
- ".githooks/pre-commit"
|
|
63
64
|
- CHANGELOG.md
|
|
64
65
|
- CODE_OF_CONDUCT.md
|
|
65
66
|
- LICENSE.txt
|
|
@@ -73,6 +74,7 @@ files:
|
|
|
73
74
|
- lib/and_one/console.rb
|
|
74
75
|
- lib/and_one/detection.rb
|
|
75
76
|
- lib/and_one/detector.rb
|
|
77
|
+
- lib/and_one/dev_toast.rb
|
|
76
78
|
- lib/and_one/dev_ui.rb
|
|
77
79
|
- lib/and_one/fingerprint.rb
|
|
78
80
|
- lib/and_one/formatter.rb
|
|
@@ -93,6 +95,7 @@ metadata:
|
|
|
93
95
|
homepage_uri: https://github.com/keiththomps/and_one
|
|
94
96
|
source_code_uri: https://github.com/keiththomps/and_one
|
|
95
97
|
changelog_uri: https://github.com/keiththomps/and_one/blob/main/CHANGELOG.md
|
|
98
|
+
rubygems_mfa_required: 'true'
|
|
96
99
|
rdoc_options: []
|
|
97
100
|
require_paths:
|
|
98
101
|
- lib
|