rails_db_inspector 0.1.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.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +232 -0
  4. data/Rakefile +3 -0
  5. data/app/assets/stylesheets/rails_db_inspector/application.css +41 -0
  6. data/app/controllers/rails_db_inspector/application_controller.rb +15 -0
  7. data/app/controllers/rails_db_inspector/queries_controller.rb +42 -0
  8. data/app/controllers/rails_db_inspector/schema_controller.rb +13 -0
  9. data/app/helpers/rails_db_inspector/application_helper.rb +274 -0
  10. data/app/helpers/rails_db_inspector/plan_renderer.rb +887 -0
  11. data/app/jobs/rails_db_inspector/application_job.rb +4 -0
  12. data/app/mailers/rails_db_inspector/application_mailer.rb +6 -0
  13. data/app/models/rails_db_inspector/application_record.rb +5 -0
  14. data/app/views/layouts/rails_db_inspector/application.html.erb +55 -0
  15. data/app/views/rails_db_inspector/queries/explain.html.erb +128 -0
  16. data/app/views/rails_db_inspector/queries/index.html.erb +258 -0
  17. data/app/views/rails_db_inspector/queries/show.html.erb +103 -0
  18. data/app/views/rails_db_inspector/schema/index.html.erb +842 -0
  19. data/config/routes.rb +17 -0
  20. data/lib/rails_db_inspector/configuration.rb +17 -0
  21. data/lib/rails_db_inspector/dev_widget_middleware.rb +145 -0
  22. data/lib/rails_db_inspector/engine.rb +22 -0
  23. data/lib/rails_db_inspector/explain/my_sql.rb +28 -0
  24. data/lib/rails_db_inspector/explain/postgres.rb +32 -0
  25. data/lib/rails_db_inspector/explain.rb +27 -0
  26. data/lib/rails_db_inspector/query_store.rb +89 -0
  27. data/lib/rails_db_inspector/schema_inspector.rb +222 -0
  28. data/lib/rails_db_inspector/sql_subscriber.rb +42 -0
  29. data/lib/rails_db_inspector/version.rb +3 -0
  30. data/lib/rails_db_inspector.rb +25 -0
  31. data/lib/tasks/rails_db_inspector_tasks.rake +4 -0
  32. metadata +91 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 406b9d3f33d6809dfd3d245f95d82c01ddf3a007775a1a8a74b83c41da5263cd
4
+ data.tar.gz: 4d5913bcc76b28f7042b7e280c966fba438848db53788f598d836fdf222d9578
5
+ SHA512:
6
+ metadata.gz: aabc8ed324f44a464c9042b40198f096222338375a7e9e8fbcad2a3690c11a290a07a2b22c4d3cefa6681a53c1c3626504c8b91d2f99f27ae5f7c74c5b712fcb
7
+ data.tar.gz: 7f6507b9b1ab845396b26dd54d8a83a5ec1a317505ced5d0075f85c85da8a13051319a72682cabf76a1d1b134ef57fd2a3afe10116b6a7838962330748e26d7e
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright samuel-murphy
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,232 @@
1
+ # Rails DB Inspector
2
+
3
+ A mountable Rails engine that gives you a built-in dashboard for **SQL query monitoring**, **N+1 detection**, **EXPLAIN / EXPLAIN ANALYZE plans**, and **interactive schema visualization** — no external services required.
4
+
5
+ Supports **PostgreSQL** and **MySQL**.
6
+
7
+ ![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-red)
8
+ ![Rails](https://img.shields.io/badge/rails-%3E%3D%207.1-red)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
10
+
11
+ ---
12
+
13
+ ## Features
14
+
15
+ - **Real-time SQL Query Capture** — every query your app executes is logged with SQL text, duration, bind parameters, and timestamps
16
+ - **N+1 Query Detection** — automatically identifies repeated query patterns and highlights the worst offenders
17
+ - **Query Grouping** — queries are grouped by controller action using Rails marginal annotations
18
+ - **EXPLAIN Plans** — run `EXPLAIN` on any captured query to see the execution plan (PostgreSQL JSON format, MySQL tabular)
19
+ - **EXPLAIN ANALYZE** — optionally run `EXPLAIN ANALYZE` to get real execution statistics, buffer usage, and timing (opt-in, SELECT only)
20
+ - **Plan Analysis** — rich visual rendering of PostgreSQL plans including cost breakdown, row estimate accuracy, index usage analysis, performance hotspots, buffer statistics, and actionable recommendations
21
+ - **Interactive Schema / ERD Visualization** — drag-and-drop entity relationship diagram with pan, zoom, search, column expansion, heat-map by row count, missing index warnings, polymorphic detection, and SVG export
22
+ - **Dev Widget** — floating button injected into your app's pages in development for quick access to the dashboard
23
+ - **Zero Dependencies** — no JavaScript build step, no external CSS frameworks, everything is self-contained
24
+
25
+ ---
26
+
27
+ ## Installation
28
+
29
+ Add to your Gemfile. It is **strongly recommended** to restrict this to `:development` (and optionally `:test`):
30
+
31
+ ```ruby
32
+ group :development do
33
+ gem "rails_db_inspector"
34
+ end
35
+ ```
36
+
37
+ Then run:
38
+
39
+ ```bash
40
+ bundle install
41
+ ```
42
+
43
+ ---
44
+
45
+ ## Setup
46
+
47
+ ### 1. Mount the Engine
48
+
49
+ Add the engine route to your `config/routes.rb`:
50
+
51
+ ```ruby
52
+ Rails.application.routes.draw do
53
+ # ... your app routes ...
54
+
55
+ if Rails.env.development?
56
+ mount RailsDbInspector::Engine, at: "/inspect"
57
+ end
58
+ end
59
+ ```
60
+
61
+ You can use any mount path — `/inspect`, `/db`, `/rails_db_inspector`, etc.
62
+
63
+ ### 2. Create an Initializer (Optional but Recommended)
64
+
65
+ Create `config/initializers/rails_db_inspector.rb`:
66
+
67
+ ```ruby
68
+ RailsDbInspector.configure do |config|
69
+ # Enable or disable the engine entirely.
70
+ # Default: true
71
+ config.enabled = Rails.env.development?
72
+
73
+ # Maximum number of queries to keep in memory.
74
+ # Older queries are trimmed when this limit is exceeded.
75
+ # Default: 2000
76
+ config.max_queries = 2_000
77
+
78
+ # Allow EXPLAIN ANALYZE to run on captured queries.
79
+ # This actually executes the query, so it is disabled by default for safety.
80
+ # Only SELECT statements are permitted even when enabled.
81
+ # Default: false
82
+ config.allow_explain_analyze = true
83
+
84
+ # Show the floating dev widget on your app's pages in development.
85
+ # The widget provides quick links to the query monitor and schema viewer.
86
+ # Default: true
87
+ config.show_widget = true
88
+ end
89
+ ```
90
+
91
+ ### Configuration Options
92
+
93
+ | Option | Type | Default | Description |
94
+ |-------------------------|---------|----------|-----------------------------------------------------------------------------|
95
+ | `enabled` | Boolean | `true` | Master switch — disables SQL subscription and widget when `false` |
96
+ | `max_queries` | Integer | `2000` | Max queries stored in memory (FIFO eviction) |
97
+ | `allow_explain_analyze` | Boolean | `false` | Permit EXPLAIN ANALYZE (executes the query — SELECT only) |
98
+ | `show_widget` | Boolean | `true` | Inject floating widget into HTML pages in development |
99
+
100
+ ---
101
+
102
+ ## Usage
103
+
104
+ ### Accessing the Dashboard
105
+
106
+ Once mounted, visit the engine in your browser:
107
+
108
+ ```
109
+ http://localhost:3000/inspect
110
+ ```
111
+
112
+ (Replace `/inspect` with whatever mount path you chose.)
113
+
114
+ ### Query Monitor
115
+
116
+ The root page shows all captured SQL queries in reverse-chronological order.
117
+
118
+ - **Grouped by Controller Action** — queries are automatically grouped using Rails' marginal SQL comments (`controller='...'`, `action='...'`). Enable annotations in your app with:
119
+
120
+ ```ruby
121
+ # config/application.rb or config/environments/development.rb
122
+ config.active_record.query_log_tags_enabled = true
123
+ config.active_record.query_log_tags = [
124
+ { controller: ->(context) { context[:controller]&.controller_name } },
125
+ { action: ->(context) { context[:controller]&.action_name } }
126
+ ]
127
+ ```
128
+
129
+ - **N+1 Detection** — the dashboard flags queries that appear 3+ times with the same normalized SQL pattern, showing the count, total duration, and table name
130
+ - **Query Type Badges** — each query is tagged with its operation (`SELECT`, `INSERT`, `UPDATE`, `DELETE`, `CTE`) and complexity hints (`JOIN`, `SUBQUERY`, `AGGREGATE`, `ORDER BY`, `WINDOW`)
131
+ - **Clear Queries** — use the "Clear" button to reset the in-memory query store
132
+
133
+ ### Running EXPLAIN
134
+
135
+ Click on any query to view its details, then click **Explain** to get the execution plan.
136
+
137
+ - **EXPLAIN** — shows the planned execution without running the query (always available)
138
+ - **EXPLAIN ANALYZE** — shows actual execution statistics (requires `allow_explain_analyze = true` in the initializer)
139
+
140
+ For PostgreSQL, the plan is rendered with:
141
+ - Visual tree of plan nodes with cost, rows, and width
142
+ - Timing and buffer statistics (ANALYZE mode)
143
+ - Row estimate accuracy indicators (color-coded)
144
+ - Warning badges for sequential scans, large sorts, nested loops, etc.
145
+ - Index usage analysis
146
+ - Performance hotspot identification
147
+ - Cache hit ratio
148
+ - Actionable recommendations (e.g., "Create index on `orders.status`")
149
+
150
+ > **⚠️ Safety:** EXPLAIN ANALYZE actually executes the query. Only `SELECT` statements are allowed — `INSERT`, `UPDATE`, and `DELETE` queries are blocked even when analyze is enabled.
151
+
152
+ ### Schema Visualization
153
+
154
+ Navigate to the **Schema** page to see an interactive entity relationship diagram:
155
+
156
+ - **Drag & drop** nodes to rearrange
157
+ - **Pan & zoom** with mouse wheel or controls
158
+ - **Search** tables with `/` keyboard shortcut
159
+ - **Click** a table to see columns, types, indexes, foreign keys, associations, and row count in the detail panel
160
+ - **Double-click** a node to expand/collapse its columns
161
+ - **Relationships** drawn from foreign keys (solid blue lines) and Rails conventions (dashed gray lines)
162
+ - **Heat map** — node headers are color-coded by row count (green → red)
163
+ - **Missing index warnings** — yellow badge on tables with `_id` columns lacking an index
164
+ - **Polymorphic detection** — purple "P" badge on tables with matching `_type`/`_id` column pairs
165
+ - **Health summary** — table count, column count, index count, total rows, missing indexes, tables without timestamps, tables without primary keys, polymorphic columns
166
+ - **Export SVG** — download the diagram as an SVG file
167
+
168
+ ### Dev Widget
169
+
170
+ In development, a floating blue button (🛢️) appears in the bottom-right corner of every page. Click it to reveal quick links to:
171
+
172
+ - **Query Monitor** — opens the query dashboard
173
+ - **Schema Visualization** — opens the ERD viewer
174
+
175
+ The widget is automatically injected via Rack middleware and only appears in `development` environment. Disable it with `config.show_widget = false`.
176
+
177
+ ---
178
+
179
+ ## Supported Databases
180
+
181
+ | Adapter | EXPLAIN | EXPLAIN ANALYZE | Schema / ERD |
182
+ |------------|---------|-----------------|--------------|
183
+ | PostgreSQL | ✅ | ✅ | ✅ |
184
+ | MySQL | ✅ | ✅ | ✅ |
185
+ | SQLite | ❌ | ❌ | ✅ |
186
+
187
+ EXPLAIN uses `FORMAT JSON` for PostgreSQL and standard `EXPLAIN` for MySQL.
188
+
189
+ ---
190
+
191
+ ## How It Works
192
+
193
+ 1. **SQL Subscriber** — uses `ActiveSupport::Notifications.subscribe("sql.active_record")` to capture every query. Schema, transaction, cached, and EXPLAIN queries are automatically filtered out.
194
+ 2. **Query Store** — an in-memory singleton (`QueryStore`) stores captured queries with thread-safe access. Oldest queries are evicted when `max_queries` is exceeded.
195
+ 3. **Explain** — wraps the captured SQL in an `EXPLAIN` statement appropriate for the database adapter and parses the result.
196
+ 4. **Schema Inspector** — introspects `ActiveRecord::Base.connection` for tables, columns, indexes, foreign keys, primary keys, row counts, associations, polymorphic columns, and missing indexes.
197
+ 5. **Dev Widget Middleware** — a Rack middleware that injects a small HTML snippet before `</body>` on HTML responses in development.
198
+
199
+ ---
200
+
201
+ ## Development / Contributing
202
+
203
+ ```bash
204
+ # Clone the repo
205
+ git clone https://github.com/h0m1c1de/rails_db_inspector.git
206
+ cd rails_db_inspector
207
+
208
+ # Install dependencies
209
+ bundle install
210
+
211
+ # Run tests
212
+ bundle exec rspec
213
+
214
+ # Run linter
215
+ bin/rubocop
216
+ ```
217
+
218
+ ### Running Tests
219
+
220
+ The test suite uses RSpec with SimpleCov for coverage:
221
+
222
+ ```bash
223
+ bundle exec rspec
224
+ ```
225
+
226
+ Coverage targets: **95% line**, **85% branch**.
227
+
228
+ ---
229
+
230
+ ## License
231
+
232
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -0,0 +1,41 @@
1
+ /*
2
+ * Rails DB Inspector - Tailwind CSS Application
3
+ *
4
+ * This gem now uses Tailwind CSS for modern, professional styling.
5
+ * Custom styles for plan trees and specific components are included below.
6
+ *= require_self
7
+ */
8
+
9
+ /* Custom styles for plan tree interactions */
10
+ .expand-toggle {
11
+ transition: all 0.2s ease-in-out;
12
+ }
13
+
14
+ .plan-node-header.collapsed + .plan-node-body {
15
+ display: none;
16
+ }
17
+
18
+ /* Custom animations */
19
+ @keyframes fadeIn {
20
+ from { opacity: 0; transform: translateY(-10px); }
21
+ to { opacity: 1; transform: translateY(0); }
22
+ }
23
+
24
+ .fade-in {
25
+ animation: fadeIn 0.3s ease-out;
26
+ }
27
+
28
+ /* Print styles */
29
+ @media print {
30
+ .no-print {
31
+ display: none !important;
32
+ }
33
+
34
+ .bg-gray-50 {
35
+ background-color: white !important;
36
+ }
37
+
38
+ .shadow, .shadow-sm {
39
+ box-shadow: none !important;
40
+ }
41
+ }
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDbInspector
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery with: :exception
6
+
7
+ before_action :ensure_enabled!
8
+
9
+ private
10
+
11
+ def ensure_enabled!
12
+ head :not_found unless RailsDbInspector.configuration.enabled
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDbInspector
4
+ class QueriesController < ApplicationController
5
+ include RailsDbInspector::ApplicationHelper
6
+
7
+ def index
8
+ all_queries = RailsDbInspector::QueryStore.instance.all.reverse
9
+ @queries = all_queries
10
+ @query_groups = group_queries_by_action(all_queries)
11
+ @n_plus_ones = detect_n_plus_one(all_queries)
12
+ end
13
+
14
+ def show
15
+ @query = RailsDbInspector::QueryStore.instance.find(params[:id])
16
+ head :not_found unless @query
17
+ end
18
+
19
+ def explain
20
+ @query = RailsDbInspector::QueryStore.instance.find(params[:id])
21
+ return head :not_found unless @query
22
+
23
+ analyze = ActiveModel::Type::Boolean.new.cast(params[:analyze])
24
+
25
+ if analyze && !RailsDbInspector.configuration.allow_explain_analyze
26
+ return render plain: "EXPLAIN ANALYZE is disabled. Enable RailsDbInspector.configuration.allow_explain_analyze = true", status: :forbidden
27
+ end
28
+
29
+ explainer = RailsDbInspector::Explain.for_connection(ActiveRecord::Base.connection)
30
+ @explain = explainer.explain(@query.sql, analyze: analyze)
31
+ rescue RailsDbInspector::Explain::DangerousQuery => e
32
+ render plain: e.message, status: :unprocessable_entity
33
+ rescue RailsDbInspector::Explain::UnsupportedAdapter => e
34
+ render plain: e.message, status: :not_implemented
35
+ end
36
+
37
+ def clear
38
+ RailsDbInspector::QueryStore.instance.clear!
39
+ redirect_to queries_path
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDbInspector
4
+ class SchemaController < ApplicationController
5
+ include RailsDbInspector::ApplicationHelper
6
+
7
+ def index
8
+ inspector = RailsDbInspector::SchemaInspector.new
9
+ @schema = inspector.introspect
10
+ @relationships = inspector.relationships
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,274 @@
1
+ require_relative "plan_renderer"
2
+
3
+ module RailsDbInspector
4
+ module ApplicationHelper
5
+ def render_postgres_plan(plan_data)
6
+ renderer = RailsDbInspector::ApplicationHelper::PostgresPlanRenderer.new(plan_data)
7
+ (renderer.render_summary + renderer.render_tree).html_safe
8
+ end
9
+
10
+ def render_query_type(query)
11
+ sql = query.sql.downcase.strip
12
+
13
+ # Determine the primary operation
14
+ operation = case sql
15
+ when /^select\b/
16
+ "SELECT"
17
+ when /^insert\b/
18
+ "INSERT"
19
+ when /^update\b/
20
+ "UPDATE"
21
+ when /^delete\b/
22
+ "DELETE"
23
+ when /^with\b/
24
+ "CTE"
25
+ else
26
+ "OTHER"
27
+ end
28
+
29
+ # Add complexity indicators for SELECT queries
30
+ complexity_hints = []
31
+
32
+ if operation == "SELECT"
33
+ complexity_hints << "JOIN" if sql.include?(" join ")
34
+ complexity_hints << "SUBQUERY" if sql.include?("(select") || sql.include?("( select")
35
+ complexity_hints << "AGGREGATE" if sql.match?(/\b(count|sum|avg|max|min|group by)\b/)
36
+ complexity_hints << "ORDER BY" if sql.include?(" order by ")
37
+ complexity_hints << "WINDOW" if sql.include?(" over(") || sql.include?(" over (")
38
+ end
39
+
40
+ # Render the operation with complexity hints
41
+ result_html = "<span class=\"inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800\">#{operation}</span>"
42
+
43
+ complexity_hints.each do |hint|
44
+ case hint
45
+ when "JOIN"
46
+ result_html += " <span class=\"inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-blue-100 text-blue-800\">#{hint}</span>"
47
+ when "AGGREGATE", "WINDOW"
48
+ result_html += " <span class=\"inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800\">#{hint}</span>"
49
+ when "SUBQUERY"
50
+ result_html += " <span class=\"inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-red-100 text-red-800\">#{hint}</span>"
51
+ else
52
+ result_html += " <span class=\"inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-green-100 text-green-800\">#{hint}</span>"
53
+ end
54
+ end
55
+
56
+ result_html.html_safe
57
+ end
58
+
59
+ def group_queries_by_action(queries)
60
+ return [] if queries.empty?
61
+
62
+ groups = []
63
+ current_group = nil
64
+
65
+ queries.each do |query|
66
+ controller_action = extract_controller_action_from_sql(query)
67
+
68
+ # Start a new group if:
69
+ # 1. No current group
70
+ # 2. Different controller/action
71
+ # 3. Time gap of more than 10 seconds from the last query in the group
72
+ if current_group.nil? ||
73
+ current_group[:action] != controller_action ||
74
+ time_gap_too_large?(query, current_group[:queries].last, 10.0)
75
+
76
+ current_group = {
77
+ action: controller_action,
78
+ queries: [],
79
+ start_time: query.timestamp,
80
+ request_type: determine_request_type_from_action(controller_action)
81
+ }
82
+ groups << current_group
83
+ end
84
+
85
+ current_group[:queries] << query
86
+ end
87
+
88
+ groups
89
+ end
90
+
91
+ def detect_n_plus_one(queries)
92
+ return [] if queries.length < 3
93
+
94
+ # Normalize queries by replacing literal values with placeholders
95
+ normalized = queries.map do |q|
96
+ {
97
+ query: q,
98
+ normalized: normalize_sql(q.sql)
99
+ }
100
+ end
101
+
102
+ # Group by normalized SQL
103
+ groups = normalized.group_by { |entry| entry[:normalized] }
104
+
105
+ # Find N+1 patterns: same normalized query appearing 3+ times
106
+ n_plus_ones = []
107
+ groups.each do |normalized_sql, entries|
108
+ next if entries.length < 3
109
+ next if normalized_sql.strip.empty?
110
+
111
+ # Skip schema/transaction queries
112
+ next if normalized_sql =~ /\A(BEGIN|COMMIT|ROLLBACK|SET|SHOW)\b/i
113
+
114
+ sample_query = entries.first[:query]
115
+ total_duration = entries.sum { |e| e[:query].duration_ms }
116
+
117
+ # Try to extract the table name
118
+ table = normalized_sql.match(/FROM\s+"?(\w+)"?/i)&.captures&.first || "unknown"
119
+
120
+ n_plus_ones << {
121
+ normalized_sql: normalized_sql,
122
+ count: entries.length,
123
+ queries: entries.map { |e| e[:query] },
124
+ sample: sample_query,
125
+ total_duration_ms: total_duration,
126
+ table: table,
127
+ name: sample_query.name
128
+ }
129
+ end
130
+
131
+ # Sort by count descending (worst offenders first)
132
+ n_plus_ones.sort_by { |n| -n[:count] }
133
+ end
134
+
135
+ private
136
+
137
+ def normalize_sql(sql)
138
+ normalized = sql.dup
139
+ # Remove SQL comments like /*...*/ (Rails marginal annotations)
140
+ normalized.gsub!(/\/\*.*?\*\//m, "")
141
+ # Replace string literals 'value' with ?
142
+ normalized.gsub!(/'[^']*'/, "?")
143
+ # Replace numeric literals (integers and floats)
144
+ normalized.gsub!(/\b\d+(\.\d+)?\b/, "?")
145
+ # Replace $1, $2 style bind params
146
+ normalized.gsub!(/\$\d+/, "?")
147
+ # Collapse whitespace
148
+ normalized.gsub!(/\s+/, " ")
149
+ normalized.strip
150
+ end
151
+
152
+ def extract_controller_action_from_sql(query)
153
+ sql = query.sql.to_s
154
+
155
+ # Look for controller and action in SQL comments
156
+ if match = sql.match(/\/\*.*?controller='([^']+)'.*?action='([^']+)'.*?\*\//)
157
+ controller = match[1]
158
+ action = match[2]
159
+
160
+ # Handle namespaced controllers properly
161
+ if controller.include?("/")
162
+ # Convert api/users to Api::UsersController
163
+ controller_parts = controller.split("/")
164
+ namespaced_controller = controller_parts.map(&:camelize).join("::") + "Controller"
165
+ else
166
+ namespaced_controller = "#{controller.camelize}Controller"
167
+ end
168
+
169
+ return "#{namespaced_controller}##{action}"
170
+ end
171
+
172
+ # Fallback to query name if no controller/action found
173
+ query.name.to_s.presence || "Unknown Query"
174
+ end
175
+
176
+ def determine_request_type_from_action(action_name)
177
+ case action_name
178
+ when /API::|Api::|\/api\//i
179
+ :api
180
+ when /Controller#/
181
+ # Check if it looks like an API endpoint based on common patterns
182
+ if action_name.match(/(show|index|create|update|destroy)/) &&
183
+ !action_name.match(/(new|edit)/)
184
+ # Could be API if no render actions (new/edit are typically web-only)
185
+ :web_or_api
186
+ else
187
+ :web_request
188
+ end
189
+ when /Load|Create|Update|Delete|Destroy/
190
+ :model_operation
191
+ when /Schema|Migration/i
192
+ :schema
193
+ else
194
+ :other
195
+ end
196
+ end
197
+
198
+ def group_icon(request_type)
199
+ case request_type
200
+ when :api
201
+ "🔗"
202
+ when :web_request
203
+ "🌐"
204
+ when :web_or_api
205
+ "🔀" # Mixed/ambiguous
206
+ when :model_operation
207
+ "📊"
208
+ when :schema
209
+ "🗂️"
210
+ else
211
+ "💾"
212
+ end
213
+ end
214
+
215
+ def time_gap_too_large?(current_query, last_query, gap_seconds = 5.0)
216
+ return false if last_query.nil?
217
+
218
+ current_time = parse_timestamp(current_query.timestamp)
219
+ last_time = parse_timestamp(last_query.timestamp)
220
+
221
+ (current_time - last_time).abs > gap_seconds
222
+ end
223
+
224
+ def parse_timestamp(timestamp)
225
+ case timestamp
226
+ when Time
227
+ timestamp
228
+ when Numeric
229
+ Time.at(timestamp)
230
+ else
231
+ Time.parse(timestamp.to_s)
232
+ end
233
+ rescue
234
+ Time.now
235
+ end
236
+
237
+ def format_group_time_range(group)
238
+ return "" if group[:queries].empty?
239
+
240
+ start_time = parse_timestamp(group[:start_time])
241
+ end_time = parse_timestamp(group[:queries].last.timestamp)
242
+
243
+ if (end_time - start_time) < 1.0
244
+ start_time.strftime("%H:%M:%S")
245
+ else
246
+ "#{start_time.strftime('%H:%M:%S')} - #{end_time.strftime('%H:%M:%S')}"
247
+ end
248
+ end
249
+
250
+ # JSON serialization helpers for schema visualization
251
+ def schema_to_json(schema)
252
+ result = {}
253
+ schema.each do |table_name, info|
254
+ result[table_name] = {
255
+ columns: info[:columns].map { |c| { name: c[:name], type: c[:type], nullable: c[:nullable], default: c[:default] } },
256
+ indexes: info[:indexes].map { |i| { name: i[:name], columns: i[:columns], unique: i[:unique] } },
257
+ foreign_keys: info[:foreign_keys].map { |fk| { column: fk[:column], to_table: fk[:to_table], primary_key: fk[:primary_key] } },
258
+ primary_key: info[:primary_key],
259
+ row_count: info[:row_count],
260
+ associations: (info[:associations] || []).map { |a| { name: a[:name], macro: a[:macro], target_table: a[:target_table], foreign_key: a[:foreign_key], through: a[:through] } },
261
+ missing_indexes: info[:missing_indexes] || [],
262
+ polymorphic_columns: (info[:polymorphic_columns] || []).map { |p| { name: p[:name], type_column: p[:type_column], id_column: p[:id_column] } }
263
+ }
264
+ end
265
+ result.to_json
266
+ end
267
+
268
+ def relationships_to_json(relationships)
269
+ relationships.map do |r|
270
+ { from_table: r[:from_table], from_column: r[:from_column], to_table: r[:to_table], to_column: r[:to_column], type: r[:type].to_s }
271
+ end.to_json
272
+ end
273
+ end
274
+ end