n_plus_insight 0.1.0 → 0.1.2
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 +8 -2
- data/LICENSE.txt +1 -1
- data/README.md +338 -41
- data/app/assets/n_plus_insight/overlay.css +11 -3
- data/app/assets/n_plus_insight/overlay.js +91 -15
- data/app/controllers/n_plus_insight/application_controller.rb +0 -0
- data/app/controllers/n_plus_insight/assets_controller.rb +0 -0
- data/app/controllers/n_plus_insight/detections_controller.rb +5 -0
- data/app/views/layouts/n_plus_insight/application.html.erb +21 -4
- data/app/views/n_plus_insight/detections/index.html.erb +17 -5
- data/app/views/n_plus_insight/detections/show.html.erb +44 -19
- data/config/routes.rb +1 -0
- data/docs/images/full-dashboard.png +0 -0
- data/docs/images/on-page-alert.png +0 -0
- data/docs/images/on-page-popout-modeled.png +0 -0
- data/lib/n_plus_insight/analyzer.rb +182 -34
- data/lib/n_plus_insight/configuration.rb +0 -0
- data/lib/n_plus_insight/current.rb +0 -0
- data/lib/n_plus_insight/detection.rb +2 -1
- data/lib/n_plus_insight/engine.rb +0 -0
- data/lib/n_plus_insight/middleware.rb +0 -0
- data/lib/n_plus_insight/overlay.rb +2 -0
- data/lib/n_plus_insight/query.rb +0 -0
- data/lib/n_plus_insight/source_location.rb +0 -0
- data/lib/n_plus_insight/store.rb +0 -0
- data/lib/n_plus_insight/subscriber.rb +0 -0
- data/lib/n_plus_insight/version.rb +1 -1
- data/lib/n_plus_insight.rb +0 -0
- metadata +17 -12
- data/RELEASING.md +0 -47
|
@@ -19,21 +19,38 @@
|
|
|
19
19
|
.list { display:grid; gap:12px; } .card { display:block; background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:18px; color:var(--text); text-decoration:none; }
|
|
20
20
|
.card:hover { border-color:var(--accent); } .row { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }
|
|
21
21
|
.badge { display:inline-flex; padding:2px 8px; border-radius:999px; background:var(--accent-soft); color:var(--text); font-size:12px; }
|
|
22
|
+
.dashboard-actions { display:flex; align-items:center; gap:10px; }
|
|
23
|
+
.button { appearance:none; border:1px solid var(--line); border-radius:8px; padding:7px 11px; background:var(--panel); color:var(--text); cursor:pointer; font:600 13px/1 ui-sans-serif,system-ui,-apple-system,sans-serif; }
|
|
24
|
+
.button:hover { border-color:var(--bad); color:var(--bad); }
|
|
25
|
+
.button:disabled { cursor:not-allowed; opacity:.5; }
|
|
26
|
+
.notice { margin:0 0 18px; padding:10px 12px; border:1px solid var(--accent); border-radius:8px; background:var(--accent-soft); }
|
|
22
27
|
.danger { color:var(--bad); font-weight:650; } .meta { display:flex; flex-wrap:wrap; gap:8px 18px; color:var(--muted); font-size:13px; }
|
|
23
28
|
.grid { display:grid; grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr); gap:20px; align-items:start; }
|
|
24
29
|
.stack { display:grid; gap:16px; } section { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:20px; }
|
|
25
30
|
pre { margin:0; overflow:auto; padding:14px; border-radius:8px; background:var(--code); color:#e5e7eb; font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; }
|
|
26
31
|
.source { padding:0; } .source div { display:grid; grid-template-columns:44px 1fr; padding:1px 14px; }
|
|
27
32
|
.source div.active { background:rgba(180,35,24,.28); } .source b { color:#9ca3af; font-weight:400; user-select:none; }
|
|
28
|
-
.
|
|
29
|
-
.
|
|
33
|
+
.query-pattern + .query-pattern { border-top:1px solid var(--line); padding-top:18px; margin-top:18px; }
|
|
34
|
+
.query-pattern h3 { margin-bottom:8px; }
|
|
35
|
+
.tree-wrap { overflow:auto; padding:4px 0; }
|
|
36
|
+
.model-tree { list-style:none; margin:0; padding:0; }
|
|
37
|
+
.model-tree .model-tree { margin-left:22px; padding-left:20px; border-left:1px solid var(--line); }
|
|
38
|
+
.model-tree li { position:relative; margin:12px 0; }
|
|
39
|
+
.model-tree .model-tree > li::before { content:""; position:absolute; left:-20px; top:30px; width:20px; border-top:1px solid var(--line); }
|
|
40
|
+
.tree-node { display:inline-grid; min-width:170px; padding:10px 12px; border:1px solid var(--accent); border-radius:9px; background:var(--accent-soft); text-align:center; }
|
|
41
|
+
.tree-node strong,.tree-node small { display:block; }
|
|
42
|
+
.tree-node small { color:var(--muted); }
|
|
43
|
+
.tree-edge { display:block; margin:0 0 5px; color:var(--muted); font-size:12px; }
|
|
30
44
|
.suggestion + .suggestion { border-top:1px solid var(--line); padding-top:18px; margin-top:18px; }
|
|
31
45
|
.crumb { display:inline-block; margin-bottom:18px; }
|
|
32
46
|
@media (max-width:760px) { .grid { grid-template-columns:1fr; } main, header div { width:min(100% - 20px,1120px); } .row { flex-direction:column; } }
|
|
33
47
|
</style>
|
|
34
48
|
</head>
|
|
35
49
|
<body>
|
|
36
|
-
<header><div><a class="brand" href="<%= root_path %>">NPlusInsight</a><span class="badge">
|
|
37
|
-
<main
|
|
50
|
+
<header><div><a class="brand" href="<%= root_path %>">NPlusInsight</a><span class="badge">Active</span></div></header>
|
|
51
|
+
<main>
|
|
52
|
+
<% if notice %><p class="notice"><%= notice %></p><% end %>
|
|
53
|
+
<%= yield %>
|
|
54
|
+
</main>
|
|
38
55
|
</body>
|
|
39
56
|
</html>
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
<div class="row">
|
|
2
2
|
<div>
|
|
3
3
|
<h1>Detected N+1 queries</h1>
|
|
4
|
-
<p class="muted">Repeated query
|
|
4
|
+
<p class="muted">Repeated query patterns grouped by request and source location.</p>
|
|
5
|
+
</div>
|
|
6
|
+
<div class="dashboard-actions">
|
|
7
|
+
<span class="badge"><%= @detections.length %> finding<%= "s" unless @detections.one? %></span>
|
|
8
|
+
<%= button_to "Clear",
|
|
9
|
+
clear_detections_path,
|
|
10
|
+
method: :delete,
|
|
11
|
+
class: "button",
|
|
12
|
+
disabled: @detections.empty?,
|
|
13
|
+
form: { onsubmit: "return window.confirm('Clear all stored N+1 detections?')" } %>
|
|
5
14
|
</div>
|
|
6
|
-
<span class="badge"><%= @detections.length %> finding<%= "s" unless @detections.one? %></span>
|
|
7
15
|
</div>
|
|
8
16
|
|
|
9
17
|
<% if @detections.empty? %>
|
|
@@ -17,14 +25,18 @@
|
|
|
17
25
|
<a class="card" href="<%= detection_path(detection.id) %>">
|
|
18
26
|
<div class="row">
|
|
19
27
|
<div>
|
|
20
|
-
<div
|
|
28
|
+
<div>
|
|
29
|
+
<span class="danger"><%= detection.query_count %> repeated queries</span>
|
|
30
|
+
across <%= detection.query_groups.length %> pattern<%= "s" unless detection.query_groups.one? %>
|
|
31
|
+
on <strong><%= detection.method %> <%= detection.path %></strong>
|
|
32
|
+
</div>
|
|
21
33
|
<div class="meta">
|
|
22
34
|
<span><%= detection.total_ms %> ms</span>
|
|
23
35
|
<% if detection.location %><span><%= detection.location.path %>:<%= detection.location.line %></span><% end %>
|
|
24
|
-
<span><%= detection.models.map { |model| model[:name] }.join("
|
|
36
|
+
<span><%= detection.models.map { |model| model[:name] }.join(", ") %></span>
|
|
25
37
|
</div>
|
|
26
38
|
</div>
|
|
27
|
-
<span aria-hidden="true"
|
|
39
|
+
<span aria-hidden="true">→</span>
|
|
28
40
|
</div>
|
|
29
41
|
</a>
|
|
30
42
|
<% end %>
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
<a class="crumb" href="<%= root_path %>"
|
|
1
|
+
<a class="crumb" href="<%= root_path %>">← All detections</a>
|
|
2
2
|
<div class="row">
|
|
3
3
|
<div>
|
|
4
4
|
<h1><%= @detection.method %> <%= @detection.path %></h1>
|
|
5
|
-
<p class="muted"
|
|
5
|
+
<p class="muted">
|
|
6
|
+
<%= @detection.query_count %> repeated queries
|
|
7
|
+
· <%= @detection.query_groups.length %> query pattern<%= "s" unless @detection.query_groups.one? %>
|
|
8
|
+
· <%= @detection.total_ms %> ms total
|
|
9
|
+
· <%= @detection.created_at %>
|
|
10
|
+
</p>
|
|
6
11
|
</div>
|
|
7
12
|
<span class="badge">N+1 detected</span>
|
|
8
13
|
</div>
|
|
@@ -15,13 +20,24 @@
|
|
|
15
20
|
<p><%= @detection.location.path %>:<%= @detection.location.line %></p>
|
|
16
21
|
<pre class="source"><% @detection.location.snippet.each do |line| %><div class="<%= "active" if line[:active] %>"><b><%= line[:line] %></b><span><%= line[:text] %></span></div><% end %></pre>
|
|
17
22
|
<% else %>
|
|
18
|
-
<p class="muted">The
|
|
23
|
+
<p class="muted">The queries were captured, but no application stack frame was available.</p>
|
|
19
24
|
<% end %>
|
|
20
25
|
</section>
|
|
21
26
|
|
|
22
27
|
<section>
|
|
23
|
-
<h2>Repeated
|
|
24
|
-
<
|
|
28
|
+
<h2>Repeated query patterns</h2>
|
|
29
|
+
<div class="query-patterns">
|
|
30
|
+
<% @detection.query_groups.each_with_index do |group, index| %>
|
|
31
|
+
<div class="query-pattern">
|
|
32
|
+
<div class="row">
|
|
33
|
+
<h3>Pattern <%= index + 1 %></h3>
|
|
34
|
+
<span class="badge"><%= group[:query_count] %> queries · <%= group[:total_ms] %> ms</span>
|
|
35
|
+
</div>
|
|
36
|
+
<p class="muted"><%= group[:tables].join(", ") %></p>
|
|
37
|
+
<pre><%= group[:sql] %></pre>
|
|
38
|
+
</div>
|
|
39
|
+
<% end %>
|
|
40
|
+
</div>
|
|
25
41
|
</section>
|
|
26
42
|
|
|
27
43
|
<section>
|
|
@@ -37,20 +53,29 @@
|
|
|
37
53
|
</div>
|
|
38
54
|
|
|
39
55
|
<section>
|
|
40
|
-
<h2>Affected model
|
|
41
|
-
<%
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
56
|
+
<h2>Affected model tree</h2>
|
|
57
|
+
<% render_tree = lambda do |nodes| %>
|
|
58
|
+
<% content_tag(:ul, class: "model-tree") do %>
|
|
59
|
+
<% safe_join(nodes.map do |node| %>
|
|
60
|
+
<% content_tag(:li) do %>
|
|
61
|
+
<% parts = [] %>
|
|
62
|
+
<% if node[:association] %>
|
|
63
|
+
<% parts << content_tag(:span, "#{node[:association]} (#{node[:macro]})", class: "tree-edge") %>
|
|
64
|
+
<% end %>
|
|
65
|
+
<% parts << content_tag(:div, class: "tree-node") do %>
|
|
66
|
+
<% safe_join([
|
|
67
|
+
content_tag(:strong, node[:name]),
|
|
68
|
+
content_tag(:small, node[:table])
|
|
69
|
+
]) %>
|
|
70
|
+
<% end %>
|
|
71
|
+
<% parts << render_tree.call(node[:children]) if node[:children].any? %>
|
|
72
|
+
<% safe_join(parts) %>
|
|
73
|
+
<% end %>
|
|
74
|
+
<% end) %>
|
|
50
75
|
<% end %>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
</
|
|
76
|
+
<% end %>
|
|
77
|
+
<div class="tree-wrap" role="img" aria-label="Tree of models and associations involved in the repeated queries">
|
|
78
|
+
<%= render_tree.call(@detection.tree) %>
|
|
79
|
+
</div>
|
|
55
80
|
</section>
|
|
56
81
|
</div>
|
data/config/routes.rb
CHANGED
|
@@ -2,5 +2,6 @@ NPlusInsight::Engine.routes.draw do
|
|
|
2
2
|
root "detections#index"
|
|
3
3
|
get "assets/overlay.css", to: "assets#stylesheet"
|
|
4
4
|
get "assets/overlay.js", to: "assets#script"
|
|
5
|
+
delete "detections", to: "detections#clear", as: :clear_detections
|
|
5
6
|
resources :detections, only: [:show]
|
|
6
7
|
end
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -6,24 +6,45 @@ module NPlusInsight
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def call
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
next if queries.length < NPlusInsight.configuration.minimum_repetitions
|
|
12
|
-
build_detection(queries)
|
|
9
|
+
repeated_shapes = @queries.group_by { |query| repeated_shape_key(query) }.values.select do |queries|
|
|
10
|
+
queries.length >= NPlusInsight.configuration.minimum_repetitions
|
|
13
11
|
end
|
|
12
|
+
|
|
13
|
+
repeated_shapes
|
|
14
|
+
.group_by { |queries| location_key(dominant_location(queries), queries.first.binds_signature) }
|
|
15
|
+
.map { |_key, query_groups| build_detection(query_groups) }
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
private
|
|
17
19
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
def repeated_shape_key(query)
|
|
21
|
+
location = query.location
|
|
22
|
+
[query.binds_signature, location&.path, location&.line]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build_detection(query_groups)
|
|
26
|
+
location = dominant_location(query_groups.flatten)
|
|
27
|
+
graph_models = {}
|
|
28
|
+
graph_edges = []
|
|
29
|
+
|
|
30
|
+
serialized_groups = query_groups.map do |queries|
|
|
31
|
+
tables = queries.flat_map(&:tables).uniq
|
|
32
|
+
model_map = model_map_for(tables)
|
|
33
|
+
inferred = infer_owner(model_map.values, location)
|
|
34
|
+
|
|
35
|
+
add_models(graph_models, tables, model_map, inferred)
|
|
36
|
+
graph_edges.concat(edges_for(model_map, inferred))
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
sql: queries.first.normalized_sql,
|
|
40
|
+
query_count: queries.length,
|
|
41
|
+
total_ms: queries.sum(&:duration_ms).round(2),
|
|
42
|
+
tables: tables
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
edges = graph_edges.uniq { |edge| [edge[:from], edge[:to], edge[:association]] }
|
|
47
|
+
models = graph_models.values
|
|
27
48
|
|
|
28
49
|
Detection.new(
|
|
29
50
|
id: SecureRandom.uuid,
|
|
@@ -31,19 +52,73 @@ module NPlusInsight
|
|
|
31
52
|
method: @request[:method],
|
|
32
53
|
path: @request[:path],
|
|
33
54
|
created_at: Time.now.utc.iso8601,
|
|
34
|
-
query_count:
|
|
35
|
-
total_ms:
|
|
36
|
-
sql:
|
|
55
|
+
query_count: serialized_groups.sum { |group| group[:query_count] },
|
|
56
|
+
total_ms: serialized_groups.sum { |group| group[:total_ms] }.round(2),
|
|
57
|
+
sql: serialized_groups.first[:sql],
|
|
58
|
+
query_groups: serialized_groups,
|
|
37
59
|
location: location,
|
|
38
60
|
models: models,
|
|
39
61
|
edges: edges,
|
|
40
|
-
|
|
62
|
+
tree: build_forest(models, edges),
|
|
63
|
+
suggestions: suggestions(edges, location)
|
|
41
64
|
)
|
|
42
65
|
end
|
|
43
66
|
|
|
67
|
+
def dominant_location(queries)
|
|
68
|
+
queries
|
|
69
|
+
.filter_map(&:location)
|
|
70
|
+
.group_by { |item| [item.path, item.line] }
|
|
71
|
+
.max_by { |_key, values| values.length }
|
|
72
|
+
&.last
|
|
73
|
+
&.first
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def location_key(location, fallback)
|
|
77
|
+
location ? [location.path, location.line] : [:query_shape, fallback]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def add_models(graph_models, tables, model_map, inferred)
|
|
81
|
+
tables.each do |table|
|
|
82
|
+
model = model_map[table]
|
|
83
|
+
name = model&.name || table.classify
|
|
84
|
+
graph_models[name] ||= { table: table, name: name, primary: false }
|
|
85
|
+
graph_models[name][:primary] = true
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
return unless inferred
|
|
89
|
+
|
|
90
|
+
[inferred[:owner], inferred[:target]].each do |model|
|
|
91
|
+
graph_models[model.name] ||= {
|
|
92
|
+
table: model.table_name,
|
|
93
|
+
name: model.name,
|
|
94
|
+
primary: tables.include?(model.table_name)
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def edges_for(model_map, inferred)
|
|
100
|
+
if inferred
|
|
101
|
+
[{
|
|
102
|
+
from: inferred[:owner].name,
|
|
103
|
+
to: inferred[:target].name,
|
|
104
|
+
association: inferred[:reflection].name,
|
|
105
|
+
macro: inferred[:reflection].macro
|
|
106
|
+
}]
|
|
107
|
+
else
|
|
108
|
+
association_edges(model_map)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
44
112
|
def model_map_for(tables)
|
|
45
113
|
return {} unless defined?(ActiveRecord::Base)
|
|
46
|
-
|
|
114
|
+
|
|
115
|
+
if defined?(Rails) &&
|
|
116
|
+
Rails.respond_to?(:application) &&
|
|
117
|
+
Rails.application&.config&.eager_load &&
|
|
118
|
+
ActiveRecord::Base.descendants.empty?
|
|
119
|
+
Rails.application.eager_load!
|
|
120
|
+
end
|
|
121
|
+
|
|
47
122
|
ActiveRecord::Base.descendants.each_with_object({}) do |model, map|
|
|
48
123
|
map[model.table_name] = model unless model.abstract_class?
|
|
49
124
|
rescue StandardError
|
|
@@ -56,13 +131,20 @@ module NPlusInsight
|
|
|
56
131
|
model.reflect_on_all_associations.filter_map do |reflection|
|
|
57
132
|
target = reflection.klass rescue nil
|
|
58
133
|
next unless target && model_map.value?(target)
|
|
59
|
-
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
from: model.name,
|
|
137
|
+
to: target.name,
|
|
138
|
+
association: reflection.name,
|
|
139
|
+
macro: reflection.macro
|
|
140
|
+
}
|
|
60
141
|
end
|
|
61
142
|
end.uniq
|
|
62
143
|
end
|
|
63
144
|
|
|
64
145
|
def infer_owner(targets, location)
|
|
65
146
|
return unless defined?(ActiveRecord::Base)
|
|
147
|
+
|
|
66
148
|
candidates = ActiveRecord::Base.descendants.flat_map do |owner|
|
|
67
149
|
owner.reflect_on_all_associations.filter_map do |reflection|
|
|
68
150
|
target = reflection.klass rescue nil
|
|
@@ -71,47 +153,113 @@ module NPlusInsight
|
|
|
71
153
|
rescue StandardError
|
|
72
154
|
[]
|
|
73
155
|
end
|
|
156
|
+
|
|
74
157
|
source = location&.snippet.to_a.map { |line| line[:text] }.join(" ")
|
|
75
158
|
receiver_match = candidates.find do |item|
|
|
76
159
|
owner_name = item[:owner].model_name.element
|
|
77
160
|
association_name = item[:reflection].name
|
|
78
161
|
source.match?(/(?:@|\b)#{Regexp.escape(owner_name)}\.#{Regexp.escape(association_name.to_s)}\b/i)
|
|
79
162
|
end
|
|
163
|
+
|
|
80
164
|
receiver_match ||
|
|
81
|
-
candidates.find
|
|
82
|
-
|
|
165
|
+
candidates.find do |item|
|
|
166
|
+
source.match?(/\.\s*#{Regexp.escape(item[:reflection].name.to_s)}\b/)
|
|
167
|
+
end ||
|
|
168
|
+
candidates.min_by { |item| item[:reflection].macro == :belongs_to ? 1 : 0 }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def build_forest(models, edges)
|
|
172
|
+
models_by_name = models.to_h { |model| [model[:name], model] }
|
|
173
|
+
children = edges.group_by { |edge| edge[:from] }
|
|
174
|
+
child_names = edges.map { |edge| edge[:to] }
|
|
175
|
+
roots = models.map { |model| model[:name] }.reject { |name| child_names.include?(name) }
|
|
176
|
+
roots = [models.first[:name]] if roots.empty? && models.any?
|
|
177
|
+
|
|
178
|
+
roots.filter_map do |name|
|
|
179
|
+
build_tree_node(name, models_by_name, children, [])
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def build_tree_node(name, models_by_name, children, ancestors, association = nil, macro = nil)
|
|
184
|
+
return if ancestors.include?(name)
|
|
185
|
+
|
|
186
|
+
model = models_by_name[name] || { name: name, table: name.to_s.tableize }
|
|
187
|
+
next_ancestors = ancestors + [name]
|
|
188
|
+
{
|
|
189
|
+
name: model[:name],
|
|
190
|
+
table: model[:table],
|
|
191
|
+
association: association,
|
|
192
|
+
macro: macro,
|
|
193
|
+
children: children.fetch(name, []).filter_map do |edge|
|
|
194
|
+
build_tree_node(
|
|
195
|
+
edge[:to],
|
|
196
|
+
models_by_name,
|
|
197
|
+
children,
|
|
198
|
+
next_ancestors,
|
|
199
|
+
edge[:association],
|
|
200
|
+
edge[:macro]
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
}
|
|
83
204
|
end
|
|
84
205
|
|
|
85
|
-
def suggestions(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
206
|
+
def suggestions(edges, location)
|
|
207
|
+
roots = (edges.map { |edge| edge[:from] } - edges.map { |edge| edge[:to] }).uniq
|
|
208
|
+
roots = [edges.first[:from]] if roots.empty? && edges.any?
|
|
209
|
+
eager_load = roots.map { |root| eager_load_for(root, edges) }.compact.join("\n")
|
|
210
|
+
eager_load = "ParentModel.includes(:association)" if eager_load.empty?
|
|
211
|
+
strict_loading = roots.any? ? roots.map { |root| "#{root}.strict_loading" }.join("\n") : "ParentModel.strict_loading"
|
|
212
|
+
association_count = edges.map { |edge| [edge[:from], edge[:association]] }.uniq.length
|
|
91
213
|
|
|
92
214
|
results = [
|
|
93
215
|
{
|
|
94
|
-
title: "Eager-load the association",
|
|
216
|
+
title: association_count > 1 ? "Eager-load all affected associations" : "Eager-load the association",
|
|
95
217
|
code: eager_load,
|
|
96
|
-
explanation:
|
|
97
|
-
|
|
218
|
+
explanation: association_count > 1 ?
|
|
219
|
+
"Preload the complete association tree before this source line executes." :
|
|
220
|
+
"Load related rows in a bounded number of queries before the loop renders them.",
|
|
221
|
+
confidence: edges.any? ? "high" : "medium"
|
|
98
222
|
},
|
|
99
223
|
{
|
|
100
224
|
title: "Use strict loading to prevent regressions",
|
|
101
|
-
code:
|
|
225
|
+
code: strict_loading,
|
|
102
226
|
explanation: "Rails will raise when code lazily loads an association that was not preloaded.",
|
|
103
227
|
confidence: "medium"
|
|
104
228
|
}
|
|
105
229
|
]
|
|
230
|
+
|
|
106
231
|
if location
|
|
107
232
|
results.unshift(
|
|
108
|
-
title: "Change the relation feeding this line",
|
|
233
|
+
title: association_count > 1 ? "Change the relation feeding this call site" : "Change the relation feeding this line",
|
|
109
234
|
code: "# #{location.path}:#{location.line}\n#{eager_load}",
|
|
110
|
-
explanation: "Apply eager loading where the
|
|
111
|
-
confidence:
|
|
235
|
+
explanation: "Apply eager loading where the root relation is constructed, not inside the iteration.",
|
|
236
|
+
confidence: edges.any? ? "high" : "medium"
|
|
112
237
|
)
|
|
113
238
|
end
|
|
239
|
+
|
|
114
240
|
results
|
|
115
241
|
end
|
|
242
|
+
|
|
243
|
+
def eager_load_for(root, edges)
|
|
244
|
+
children = edges.group_by { |edge| edge[:from] }
|
|
245
|
+
associations = format_associations(root, children, [])
|
|
246
|
+
return if associations.empty?
|
|
247
|
+
|
|
248
|
+
"#{root}.includes(#{associations.join(', ')})"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def format_associations(model_name, children, ancestors)
|
|
252
|
+
return [] if ancestors.include?(model_name)
|
|
253
|
+
|
|
254
|
+
children.fetch(model_name, []).map do |edge|
|
|
255
|
+
nested = format_associations(edge[:to], children, ancestors + [model_name])
|
|
256
|
+
if nested.empty?
|
|
257
|
+
":#{edge[:association]}"
|
|
258
|
+
else
|
|
259
|
+
value = nested.one? ? nested.first : "[#{nested.join(', ')}]"
|
|
260
|
+
"{ #{edge[:association]}: #{value} }"
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
116
264
|
end
|
|
117
265
|
end
|
|
File without changes
|
|
File without changes
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
module NPlusInsight
|
|
2
2
|
Detection = Struct.new(
|
|
3
3
|
:id, :request_id, :method, :path, :created_at, :query_count, :total_ms,
|
|
4
|
-
:sql, :location, :models, :edges, :
|
|
4
|
+
:sql, :query_groups, :location, :models, :edges, :tree, :suggestions,
|
|
5
|
+
keyword_init: true
|
|
5
6
|
) do
|
|
6
7
|
def as_json(*)
|
|
7
8
|
members.to_h { |member| [member, public_send(member)] }
|
|
File without changes
|
|
File without changes
|
|
@@ -22,9 +22,11 @@ module NPlusInsight
|
|
|
22
22
|
query_count: detection.query_count,
|
|
23
23
|
total_ms: detection.total_ms,
|
|
24
24
|
sql: detection.sql,
|
|
25
|
+
query_groups: detection.query_groups,
|
|
25
26
|
location: detection.location&.as_json,
|
|
26
27
|
models: detection.models,
|
|
27
28
|
edges: detection.edges,
|
|
29
|
+
tree: detection.tree,
|
|
28
30
|
suggestions: detection.suggestions
|
|
29
31
|
}
|
|
30
32
|
end
|
data/lib/n_plus_insight/query.rb
CHANGED
|
File without changes
|
|
File without changes
|
data/lib/n_plus_insight/store.rb
CHANGED
|
File without changes
|
|
File without changes
|
data/lib/n_plus_insight.rb
CHANGED
|
File without changes
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: n_plus_insight
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
8
|
-
autorequire:
|
|
7
|
+
- Jory Leech
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: rails
|
|
@@ -30,10 +29,11 @@ dependencies:
|
|
|
30
29
|
- - "<"
|
|
31
30
|
- !ruby/object:Gem::Version
|
|
32
31
|
version: '9'
|
|
33
|
-
description: A Rails engine that
|
|
34
|
-
|
|
35
|
-
recommends eager-loading fixes.
|
|
32
|
+
description: A Rails engine that groups N+1 query patterns by source line, visualizes
|
|
33
|
+
affected Active Record associations as trees in an on-page inspector and dashboard,
|
|
34
|
+
and recommends eager-loading fixes.
|
|
36
35
|
email:
|
|
36
|
+
- joryleech@gmail.com
|
|
37
37
|
executables: []
|
|
38
38
|
extensions: []
|
|
39
39
|
extra_rdoc_files: []
|
|
@@ -41,7 +41,6 @@ files:
|
|
|
41
41
|
- CHANGELOG.md
|
|
42
42
|
- LICENSE.txt
|
|
43
43
|
- README.md
|
|
44
|
-
- RELEASING.md
|
|
45
44
|
- app/assets/n_plus_insight/overlay.css
|
|
46
45
|
- app/assets/n_plus_insight/overlay.js
|
|
47
46
|
- app/controllers/n_plus_insight/application_controller.rb
|
|
@@ -51,6 +50,9 @@ files:
|
|
|
51
50
|
- app/views/n_plus_insight/detections/index.html.erb
|
|
52
51
|
- app/views/n_plus_insight/detections/show.html.erb
|
|
53
52
|
- config/routes.rb
|
|
53
|
+
- docs/images/full-dashboard.png
|
|
54
|
+
- docs/images/on-page-alert.png
|
|
55
|
+
- docs/images/on-page-popout-modeled.png
|
|
54
56
|
- lib/n_plus_insight.rb
|
|
55
57
|
- lib/n_plus_insight/analyzer.rb
|
|
56
58
|
- lib/n_plus_insight/configuration.rb
|
|
@@ -64,13 +66,17 @@ files:
|
|
|
64
66
|
- lib/n_plus_insight/store.rb
|
|
65
67
|
- lib/n_plus_insight/subscriber.rb
|
|
66
68
|
- lib/n_plus_insight/version.rb
|
|
67
|
-
homepage:
|
|
69
|
+
homepage: https://github.com/joryleech/NPlusInsight
|
|
68
70
|
licenses:
|
|
69
71
|
- MIT
|
|
70
72
|
metadata:
|
|
71
73
|
allowed_push_host: https://rubygems.org
|
|
72
74
|
rubygems_mfa_required: 'true'
|
|
73
|
-
|
|
75
|
+
homepage_uri: https://github.com/joryleech/NPlusInsight
|
|
76
|
+
source_code_uri: https://github.com/joryleech/NPlusInsight/tree/master
|
|
77
|
+
documentation_uri: https://github.com/joryleech/NPlusInsight/blob/master/README.md
|
|
78
|
+
changelog_uri: https://github.com/joryleech/NPlusInsight/blob/master/CHANGELOG.md
|
|
79
|
+
bug_tracker_uri: https://github.com/joryleech/NPlusInsight/issues
|
|
74
80
|
rdoc_options: []
|
|
75
81
|
require_paths:
|
|
76
82
|
- lib
|
|
@@ -85,8 +91,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
85
91
|
- !ruby/object:Gem::Version
|
|
86
92
|
version: '0'
|
|
87
93
|
requirements: []
|
|
88
|
-
rubygems_version:
|
|
89
|
-
signing_key:
|
|
94
|
+
rubygems_version: 4.0.16
|
|
90
95
|
specification_version: 4
|
|
91
96
|
summary: Detect, locate, visualize, and fix Rails N+1 queries
|
|
92
97
|
test_files: []
|