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.
@@ -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
- .graph { width:100%; min-height:240px; } .graph line { stroke:var(--line); stroke-width:2; }
29
- .graph rect { fill:var(--accent-soft); stroke:var(--accent); } .graph text { fill:var(--text); font:13px ui-sans-serif,system-ui; }
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">Development only</span></div></header>
37
- <main><%= yield %></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 shapes grouped by request, with the application frame that triggered them.</p>
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><span class="danger"><%= detection.query_count %> repeated queries</span> on <strong><%= detection.method %> <%= detection.path %></strong></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(" ") %></span>
36
+ <span><%= detection.models.map { |model| model[:name] }.join(", ") %></span>
25
37
  </div>
26
38
  </div>
27
- <span aria-hidden="true">→</span>
39
+ <span aria-hidden="true">&rarr;</span>
28
40
  </div>
29
41
  </a>
30
42
  <% end %>
@@ -1,8 +1,13 @@
1
- <a class="crumb" href="<%= root_path %>">← All detections</a>
1
+ <a class="crumb" href="<%= root_path %>">&larr; All detections</a>
2
2
  <div class="row">
3
3
  <div>
4
4
  <h1><%= @detection.method %> <%= @detection.path %></h1>
5
- <p class="muted"><%= @detection.query_count %> repeated queries · <%= @detection.total_ms %> ms total · <%= @detection.created_at %></p>
5
+ <p class="muted">
6
+ <%= @detection.query_count %> repeated queries
7
+ &middot; <%= @detection.query_groups.length %> query pattern<%= "s" unless @detection.query_groups.one? %>
8
+ &middot; <%= @detection.total_ms %> ms total
9
+ &middot; <%= @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 query was captured, but no application stack frame was available.</p>
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 SQL shape</h2>
24
- <pre><%= @detection.sql %></pre>
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 &middot; <%= 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 graph</h2>
41
- <% models = @detection.models.presence || [{ name: "Unknown model", table: "unknown" }] %>
42
- <% height = [models.length * 100, 240].max %>
43
- <svg class="graph" viewBox="0 0 360 <%= height %>" role="img" aria-label="Models involved in the repeated query">
44
- <% models.each_with_index do |model, index| %>
45
- <% y = 25 + index * 100 %>
46
- <% if index.positive? %><line x1="180" y1="<%= y - 50 %>" x2="180" y2="<%= y %>"/><% end %>
47
- <rect x="70" y="<%= y %>" width="220" height="58" rx="9"/>
48
- <text x="180" y="<%= y + 25 %>" text-anchor="middle"><%= model[:name] %></text>
49
- <text x="180" y="<%= y + 44 %>" text-anchor="middle" opacity=".65"><%= model[:table] %></text>
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
- <% @detection.edges.each_with_index do |edge, index| %>
52
- <text x="190" y="<%= 20 + (index + 1) * 100 %>" opacity=".7"><%= edge[:association] %></text>
53
- <% end %>
54
- </svg>
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
@@ -6,24 +6,45 @@ module NPlusInsight
6
6
  end
7
7
 
8
8
  def call
9
- groups = @queries.group_by(&:binds_signature)
10
- groups.filter_map do |_shape, queries|
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 build_detection(queries)
19
- tables = queries.flat_map(&:tables).uniq
20
- model_map = model_map_for(tables)
21
- location = queries.filter_map(&:location).group_by { |item| [item.path, item.line] }.max_by { |_key, values| values.length }&.last&.first
22
- inferred = infer_owner(model_map.values, location)
23
- graph_models = ([inferred&.fetch(:owner, nil)] + model_map.values).compact.uniq
24
- models = graph_models.map { |model| { table: model.table_name, name: model.name, primary: tables.include?(model.table_name) } }
25
- models = tables.map { |table| { table: table, name: table.classify, primary: true } } if models.empty?
26
- edges = inferred ? [{ from: inferred[:owner].name, to: inferred[:target].name, association: inferred[:reflection].name, macro: inferred[:reflection].macro }] : association_edges(model_map)
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: queries.length,
35
- total_ms: queries.sum(&:duration_ms).round(2),
36
- sql: queries.first.normalized_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
- suggestions: suggestions(model_map, tables, location, inferred)
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
- Rails.application.eager_load! if Rails.application.config.eager_load && ActiveRecord::Base.descendants.empty?
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
- { from: model.name, to: target.name, association: reflection.name, macro: reflection.macro }
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 { |item| source.match?(/\b#{Regexp.escape(item[:reflection].name.to_s)}\b/) } ||
82
- candidates.first
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(model_map, tables, location, inferred)
86
- edges = inferred ? [{ from: inferred[:owner].name, association: inferred[:reflection].name }] : association_edges(model_map)
87
- association = edges.first&.fetch(:association, nil)
88
- owner = edges.first&.fetch(:from, nil)
89
- relation = owner || "ParentModel"
90
- eager_load = association ? "#{relation}.includes(:#{association})" : "ParentModel.includes(:association)"
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: "Load related rows in a bounded number of queries before the loop renders them.",
97
- confidence: association ? "high" : "medium"
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: "#{relation}.strict_loading",
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 parent relation is constructed, not inside the iteration.",
111
- confidence: association ? "high" : "medium"
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, :suggestions, keyword_init: true
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
File without changes
File without changes
File without changes
File without changes
@@ -1,3 +1,3 @@
1
1
  module NPlusInsight
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.2"
3
3
  end
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.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
- - NPlusInsight contributors
8
- autorequire:
7
+ - Jory Leech
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-07-28 00:00:00.000000000 Z
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 detects repeated query shapes per request, pinpoints
34
- the application code responsible, visualizes affected Active Record models, and
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
- post_install_message:
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: 3.5.22
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: []