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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +232 -0
- data/Rakefile +3 -0
- data/app/assets/stylesheets/rails_db_inspector/application.css +41 -0
- data/app/controllers/rails_db_inspector/application_controller.rb +15 -0
- data/app/controllers/rails_db_inspector/queries_controller.rb +42 -0
- data/app/controllers/rails_db_inspector/schema_controller.rb +13 -0
- data/app/helpers/rails_db_inspector/application_helper.rb +274 -0
- data/app/helpers/rails_db_inspector/plan_renderer.rb +887 -0
- data/app/jobs/rails_db_inspector/application_job.rb +4 -0
- data/app/mailers/rails_db_inspector/application_mailer.rb +6 -0
- data/app/models/rails_db_inspector/application_record.rb +5 -0
- data/app/views/layouts/rails_db_inspector/application.html.erb +55 -0
- data/app/views/rails_db_inspector/queries/explain.html.erb +128 -0
- data/app/views/rails_db_inspector/queries/index.html.erb +258 -0
- data/app/views/rails_db_inspector/queries/show.html.erb +103 -0
- data/app/views/rails_db_inspector/schema/index.html.erb +842 -0
- data/config/routes.rb +17 -0
- data/lib/rails_db_inspector/configuration.rb +17 -0
- data/lib/rails_db_inspector/dev_widget_middleware.rb +145 -0
- data/lib/rails_db_inspector/engine.rb +22 -0
- data/lib/rails_db_inspector/explain/my_sql.rb +28 -0
- data/lib/rails_db_inspector/explain/postgres.rb +32 -0
- data/lib/rails_db_inspector/explain.rb +27 -0
- data/lib/rails_db_inspector/query_store.rb +89 -0
- data/lib/rails_db_inspector/schema_inspector.rb +222 -0
- data/lib/rails_db_inspector/sql_subscriber.rb +42 -0
- data/lib/rails_db_inspector/version.rb +3 -0
- data/lib/rails_db_inspector.rb +25 -0
- data/lib/tasks/rails_db_inspector_tasks.rake +4 -0
- 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
|
+

|
|
8
|
+

|
|
9
|
+
[](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,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
|