rails_db_inspector 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 563499f106cd48b6fb921b627408e92cf17388416ab8980485e18e4060c2c1e6
4
- data.tar.gz: 357910264b9a52d1e9c944e5ff53c8be679ec20ebc7bb4cebf5109866396d48b
3
+ metadata.gz: '08c6511ec0aa29b649a9939c4bf17ea042346cc99a5bdb645382764621589da6'
4
+ data.tar.gz: 9a171cdcd171cfd8a64488bb2b5f570175d9e26979866743c5f8571c3fd1ed5f
5
5
  SHA512:
6
- metadata.gz: accc48fc04b30608198e8b02238a6bdc49de9b75936b60521a5002edfeef2c3bd98b9beb8e6afc63b2f9e9815838bde4dc4055f0addf27c814855c9178d30298
7
- data.tar.gz: 25e5c3919cbbdf126c4f4ff6186b7abe10fb97fb8ade2a28c589de7249a2d7edc1370848ab9c29316906a69e7ade51827b03c6b0a20a595f43b0ddf8f362aa07
6
+ metadata.gz: 3a1df1506a50af86702c516e9994b54a1ec488c3dfa23115c27836c6699a3eae10f961239bc361da14771d26a995ef3c80864d0e11cdd7629e381a71d4d73db7
7
+ data.tar.gz: bc17122e5f23aadfb6edd76911f3d26b6fac1d88c13b8ac048f3134525a22ae5add60d188702055297f848cf18d7fffd1de2e8e4fd74c9f63eccea184ebbfcfb
data/README.md CHANGED
@@ -22,6 +22,7 @@ Supports **PostgreSQL**, **MySQL**, and **SQLite**.
22
22
  - **EXPLAIN ANALYZE** — optionally run `EXPLAIN ANALYZE` to get real execution statistics, buffer usage, and timing (opt-in, SELECT only)
23
23
  - **Plan Analysis** — rich visual rendering of PostgreSQL plans including cost breakdown, row estimate accuracy, index usage analysis, performance hotspots, buffer statistics, and actionable recommendations
24
24
  - **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
25
+ - **SQL Console** — interactive query editor with syntax-aware snippets, query history, table browser, EXPLAIN support, and read-only safety by default
25
26
  - **Dev Widget** — floating button injected into your app's pages in development for quick access to the dashboard
26
27
  - **Zero Dependencies** — no JavaScript build step, no external CSS frameworks, everything is self-contained
27
28
 
@@ -84,6 +85,11 @@ RailsDbInspector.configure do |config|
84
85
  # Default: false
85
86
  config.allow_explain_analyze = true
86
87
 
88
+ # Allow write queries (INSERT, UPDATE, DELETE) in the SQL Console.
89
+ # Destructive DDL (DROP, TRUNCATE, ALTER, CREATE, GRANT, REVOKE) is always blocked.
90
+ # Default: false
91
+ config.allow_console_writes = false
92
+
87
93
  # Show the floating dev widget on your app's pages in development.
88
94
  # The widget provides quick links to the query monitor and schema viewer.
89
95
  # Default: true
@@ -98,6 +104,7 @@ end
98
104
  | `enabled` | Boolean | `true` | Master switch — disables SQL subscription and widget when `false` |
99
105
  | `max_queries` | Integer | `2000` | Max queries stored in memory (FIFO eviction) |
100
106
  | `allow_explain_analyze` | Boolean | `false` | Permit EXPLAIN ANALYZE (executes the query — SELECT only) |
107
+ | `allow_console_writes` | Boolean | `false` | Allow write queries (`INSERT`, `UPDATE`, `DELETE`) in the SQL Console |
101
108
  | `show_widget` | Boolean | `true` | Inject floating widget into HTML pages in development |
102
109
 
103
110
  ---
@@ -168,6 +175,27 @@ Navigate to the **Schema** page to see an interactive entity relationship diagra
168
175
  - **Health summary** — table count, column count, index count, total rows, missing indexes, tables without timestamps, tables without primary keys, polymorphic columns
169
176
  - **Export SVG** — download the diagram as an SVG file
170
177
 
178
+ ### SQL Console
179
+
180
+ Navigate to the **Console** page for an interactive SQL editor:
181
+
182
+ - **Write and run** raw SQL queries against your development database
183
+ - **Adapter-aware snippets** — pre-built queries organized by category (Overview, Explore, Performance, Schema) that adapt to your database adapter
184
+ - **Table browser** — click any table in the sidebar to insert a `SELECT * FROM <table> LIMIT 20` query
185
+ - **Query history** — the last 50 queries are kept in-session with duration and status
186
+ - **EXPLAIN** — run an EXPLAIN plan on any query directly from the editor
187
+ - **Keyboard shortcuts** — `⌘/Ctrl + Enter` to run, `⌘/Ctrl + E` to explain
188
+
189
+ #### Safety
190
+
191
+ The console is **read-only by default** — only `SELECT`, `EXPLAIN`, `ANALYZE`, `PRAGMA`, `SHOW`, `DESCRIBE`, and `WITH` statements are permitted. Enable write access with:
192
+
193
+ ```ruby
194
+ config.allow_console_writes = true
195
+ ```
196
+
197
+ Destructive DDL (`DROP`, `TRUNCATE`, `ALTER`, `CREATE`, `GRANT`, `REVOKE`) is **always blocked** regardless of settings.
198
+
171
199
  ### Dev Widget
172
200
 
173
201
  In development, a floating blue button (🛢️) appears in the bottom-right corner of every page. Click it to reveal quick links to:
@@ -197,7 +225,8 @@ EXPLAIN uses `FORMAT JSON` for PostgreSQL, standard `EXPLAIN` for MySQL, and `EX
197
225
  2. **Query Store** — an in-memory singleton (`QueryStore`) stores captured queries with thread-safe access. Oldest queries are evicted when `max_queries` is exceeded.
198
226
  3. **Explain** — wraps the captured SQL in an `EXPLAIN` statement appropriate for the database adapter and parses the result.
199
227
  4. **Schema Inspector** — introspects `ActiveRecord::Base.connection` for tables, columns, indexes, foreign keys, primary keys, row counts, associations, polymorphic columns, and missing indexes.
200
- 5. **Dev Widget Middleware** — a Rack middleware that injects a small HTML snippet before `</body>` on HTML responses in development.
228
+ 5. **SQL Console** — runs user-provided SQL through `ActiveRecord::Base.connection.exec_query` with statement-level allow/deny lists to enforce read-only safety.
229
+ 6. **Dev Widget Middleware** — a Rack middleware that injects a small HTML snippet before `</body>` on HTML responses in development.
201
230
 
202
231
  ---
203
232
 
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDbInspector
4
+ class ConsoleController < ApplicationController
5
+ DANGEROUS_KEYWORDS = /\b(DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE)\b/i
6
+
7
+ def index
8
+ connection = ActiveRecord::Base.connection
9
+ @tables = connection.tables.reject { |t| t.match?(/^(schema_migrations|ar_internal_metadata)$/) }.sort
10
+ @adapter = connection.adapter_name.downcase
11
+ @allow_writes = RailsDbInspector.configuration.allow_console_writes
12
+ end
13
+
14
+ def execute
15
+ sql = params[:sql].to_s.strip
16
+ return render json: { error: "SQL query is required" }, status: :bad_request if sql.blank?
17
+
18
+ # Block destructive DDL regardless of config
19
+ if sql.match?(DANGEROUS_KEYWORDS)
20
+ return render json: { error: "Destructive DDL statements (DROP, TRUNCATE, ALTER, CREATE, GRANT, REVOKE) are not allowed." }, status: :forbidden
21
+ end
22
+
23
+ # Block writes unless explicitly enabled
24
+ unless RailsDbInspector.configuration.allow_console_writes
25
+ unless sql.match?(/\A\s*(SELECT|EXPLAIN|ANALYZE|PRAGMA|SHOW|DESCRIBE|DESC|\\.d|WITH)\b/i)
26
+ return render json: { error: "Write queries are disabled. Enable with: RailsDbInspector.configuration.allow_console_writes = true" }, status: :forbidden
27
+ end
28
+ end
29
+
30
+ connection = ActiveRecord::Base.connection
31
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
32
+
33
+ begin
34
+ result = connection.exec_query(sql)
35
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
36
+
37
+ render json: {
38
+ columns: result.columns,
39
+ rows: result.rows,
40
+ row_count: result.rows.length,
41
+ duration_ms: elapsed
42
+ }
43
+ rescue StandardError => e
44
+ render json: { error: e.message }, status: :unprocessable_entity
45
+ end
46
+ end
47
+ end
48
+ end
@@ -28,6 +28,7 @@ module RailsDbInspector
28
28
 
29
29
  explainer = RailsDbInspector::Explain.for_connection(ActiveRecord::Base.connection)
30
30
  @explain = explainer.explain(@query.sql, analyze: analyze)
31
+ @tables = ActiveRecord::Base.connection.tables.reject { |t| t.match?(/^(schema_migrations|ar_internal_metadata)$/) }.sort
31
32
  rescue RailsDbInspector::Explain::DangerousQuery => e
32
33
  render plain: e.message, status: :unprocessable_entity
33
34
  rescue RailsDbInspector::Explain::UnsupportedAdapter => e
@@ -8,6 +8,32 @@ module RailsDbInspector
8
8
  inspector = RailsDbInspector::SchemaInspector.new
9
9
  @schema = inspector.introspect
10
10
  @relationships = inspector.relationships
11
+ @table_sizes = inspector.table_sizes
12
+ end
13
+
14
+ def analyze_table
15
+ table = params[:table].to_s.gsub(/[^a-zA-Z0-9_]/, "")
16
+ return render json: { error: "Table name required" }, status: :bad_request if table.blank?
17
+
18
+ connection = ActiveRecord::Base.connection
19
+ adapter = connection.adapter_name.downcase
20
+
21
+ begin
22
+ case adapter
23
+ when /postgres/
24
+ connection.execute("ANALYZE #{connection.quote_table_name(table)}")
25
+ when /mysql/
26
+ connection.execute("ANALYZE TABLE #{connection.quote_table_name(table)}")
27
+ when /sqlite/
28
+ connection.execute("ANALYZE #{connection.quote_table_name(table)}")
29
+ else
30
+ return render json: { error: "ANALYZE not supported for #{adapter}" }, status: :unprocessable_entity
31
+ end
32
+
33
+ render json: { success: true, table: table, adapter: adapter, message: "Statistics refreshed for '#{table}'" }
34
+ rescue StandardError => e
35
+ render json: { error: e.message }, status: :unprocessable_entity
36
+ end
11
37
  end
12
38
  end
13
39
  end
@@ -26,9 +26,12 @@ module RailsDbInspector
26
26
  buffer_stats = collect_buffer_stats(root_plan["Plan"]) if @analyze
27
27
  recommendations = generate_recommendations(root_plan, index_analysis, buffer_stats)
28
28
 
29
- summary_html = <<~HTML
29
+ # Build verdict card
30
+ summary_html = render_verdict(execution_time, total_cost, index_analysis, recommendations)
31
+
32
+ summary_html += <<~HTML
30
33
  <div class="bg-gray-50 border border-gray-200 rounded-lg p-6 mb-6">
31
- <h3 class="text-lg font-semibold text-gray-900 mb-4">Execution Summary</h3>
34
+ <h3 class="text-lg font-semibold text-gray-900 mb-4">Performance Metrics</h3>
32
35
  <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
33
36
  HTML
34
37
 
@@ -81,6 +84,22 @@ module RailsDbInspector
81
84
  </div>
82
85
  HTML
83
86
 
87
+ # Add cardinality accuracy card (ANALYZE only)
88
+ if @analyze
89
+ accuracy = cardinality_accuracy(root_plan["Plan"])
90
+ if accuracy
91
+ accuracy_color = accuracy[:worst_ratio] <= 2.0 ? "border-green-400" : (accuracy[:worst_ratio] <= 10.0 ? "border-yellow-400" : "border-red-400")
92
+ accuracy_label = accuracy[:worst_ratio] <= 2.0 ? "Good" : (accuracy[:worst_ratio] <= 10.0 ? "Fair" : "Poor")
93
+ summary_html += <<~HTML
94
+ <div class="bg-white p-4 rounded-md border-l-4 #{accuracy_color}">
95
+ <div class="text-xs font-medium text-gray-500 uppercase tracking-wide">Cardinality Accuracy</div>
96
+ <div class="text-lg font-semibold text-gray-900 font-mono">#{accuracy_label}</div>
97
+ <div class="text-xs text-gray-400 mt-1">Worst estimate was #{accuracy[:worst_ratio]}x off#{accuracy[:worst_table] ? " on #{accuracy[:worst_table]}" : ""}. Run ANALYZE on tables with poor estimates.</div>
98
+ </div>
99
+ HTML
100
+ end
101
+ end
102
+
84
103
  # Add cache hit ratio if we have buffer stats
85
104
  if buffer_stats && buffer_stats[:total_blocks] > 0
86
105
  hit_ratio = ((buffer_stats[:hit_blocks].to_f / buffer_stats[:total_blocks]) * 100).round(1)
@@ -234,6 +253,70 @@ module RailsDbInspector
234
253
 
235
254
  private
236
255
 
256
+ def render_verdict(execution_time, total_cost, index_analysis, recommendations)
257
+ critical_count = recommendations.count { |r| r[:severity] == :critical }
258
+ warning_count = recommendations.count { |r| r[:severity] == :warning }
259
+ all_indexed = index_analysis[:total_scans] > 0 && index_analysis[:index_scans] == index_analysis[:total_scans]
260
+
261
+ if @analyze && execution_time
262
+ if execution_time > 1000 || critical_count > 0
263
+ verdict = :bad
264
+ icon = "🔴"
265
+ title = "This query needs attention"
266
+ desc = []
267
+ desc << "Took #{execution_time}ms to execute" if execution_time > 1000
268
+ desc << "#{critical_count} critical issue#{"s" if critical_count != 1}" if critical_count > 0
269
+ desc << "No indexes used" unless all_indexed || index_analysis[:total_scans] == 0
270
+ description = desc.join(" · ") + ". See the recommendations below for specific fixes."
271
+ elsif execution_time > 100 || warning_count > 0
272
+ verdict = :ok
273
+ icon = "🟡"
274
+ title = "Room for improvement"
275
+ desc = []
276
+ desc << "#{execution_time}ms execution time (aim for <50ms in web requests)" if execution_time > 100
277
+ desc << "#{warning_count} suggestion#{"s" if warning_count != 1}" if warning_count > 0
278
+ description = desc.join(" · ") + ". Review the recommendations below."
279
+ else
280
+ verdict = :good
281
+ icon = "🟢"
282
+ title = "This query looks good"
283
+ description = "Executed in #{execution_time}ms"
284
+ description += ", all scans use indexes" if all_indexed && index_analysis[:total_scans] > 0
285
+ description += ". No critical issues found."
286
+ end
287
+ else
288
+ # EXPLAIN without ANALYZE — evaluate based on cost and plan shape
289
+ if critical_count > 0
290
+ verdict = :bad
291
+ icon = "🔴"
292
+ title = "Potential problems detected"
293
+ description = "#{critical_count + warning_count} issue#{"s" if critical_count + warning_count != 1} found in the estimated plan. Run with ANALYZE to confirm with real metrics."
294
+ elsif warning_count > 0 || !all_indexed
295
+ verdict = :ok
296
+ icon = "🟡"
297
+ title = "Some concerns in the estimated plan"
298
+ description = "The planner's strategy has #{warning_count} suggestion#{"s" if warning_count != 1}. Run with ANALYZE to see actual performance."
299
+ else
300
+ verdict = :good
301
+ icon = "🟢"
302
+ title = "Estimated plan looks efficient"
303
+ description = "The planner chose index-based access"
304
+ description += " with an estimated cost of #{total_cost}" if total_cost
305
+ description += ". Run with ANALYZE to verify with real numbers."
306
+ end
307
+ end
308
+
309
+ <<~HTML
310
+ <div class="verdict-card verdict-#{verdict}" style="margin-bottom: 24px;">
311
+ <span class="verdict-icon">#{icon}</span>
312
+ <div class="verdict-body">
313
+ <h3>#{ERB::Util.html_escape(title)}</h3>
314
+ <p>#{ERB::Util.html_escape(description)}</p>
315
+ </div>
316
+ </div>
317
+ HTML
318
+ end
319
+
237
320
  def render_node(node, depth)
238
321
  node_id = "node_#{SecureRandom.hex(6)}"
239
322
  warnings = detect_warnings(node)
@@ -371,7 +454,30 @@ module RailsDbInspector
371
454
  details << [ "Cost", "#{node["Startup Cost"]}..#{node["Total Cost"]}", "Startup cost (before first row) to total cost (all rows). Arbitrary units — compare relative to other nodes." ]
372
455
  end
373
456
 
457
+ # Cardinality metrics — estimated rows and row width
458
+ if node["Plan Rows"]
459
+ details << [ "Estimated Rows (Cardinality)", number_with_delimiter(node["Plan Rows"]), "The planner's estimate of how many rows this node will produce. This is the estimated cardinality — a key factor in plan selection." ]
460
+ end
461
+
462
+ if node["Plan Width"]
463
+ details << [ "Estimated Row Width", "#{node["Plan Width"]} bytes", "Average width of each output row in bytes. Combined with cardinality (row count), this determines memory and I/O cost estimates." ]
464
+ end
465
+
374
466
  if @analyze
467
+ if node["Actual Rows"]
468
+ details << [ "Actual Rows", number_with_delimiter(node["Actual Rows"]), "The real number of rows returned by this node. Compare with Estimated Rows to judge planner accuracy." ]
469
+ end
470
+
471
+ if node["Actual Rows"] && node["Plan Rows"] && node["Plan Rows"] > 0
472
+ ratio = (node["Actual Rows"].to_f / node["Plan Rows"]).round(2)
473
+ accuracy = if ratio == 1.0 then "exact match"
474
+ elsif ratio > 0.5 && ratio < 2.0 then "good (within 2x)"
475
+ elsif ratio > 0.1 && ratio < 10 then "off (#{ratio}x)"
476
+ else "poor (#{ratio}x) — consider ANALYZE on this table"
477
+ end
478
+ details << [ "Estimate Accuracy", accuracy, "How close the planner's cardinality estimate was to reality. If poor, run ANALYZE to update table statistics." ]
479
+ end
480
+
375
481
  if node["Actual Startup Time"] && node["Actual Total Time"]
376
482
  details << [ "Actual Time", "#{node["Actual Startup Time"]}..#{node["Actual Total Time"]} ms", "Real time: from start until all rows returned for this node." ]
377
483
  end
@@ -520,6 +626,26 @@ module RailsDbInspector
520
626
  node["Plans"] && node["Plans"].any?
521
627
  end
522
628
 
629
+ def cardinality_accuracy(plan_node, result = nil)
630
+ result ||= { worst_ratio: 1.0, worst_table: nil }
631
+
632
+ if plan_node["Actual Rows"] && plan_node["Plan Rows"] && plan_node["Plan Rows"] > 0
633
+ ratio = plan_node["Actual Rows"].to_f / plan_node["Plan Rows"]
634
+ ratio = 1.0 / ratio if ratio > 0 && ratio < 1.0 # normalize so ratio >= 1
635
+ ratio = ratio.round(1)
636
+ if ratio > result[:worst_ratio]
637
+ result[:worst_ratio] = ratio
638
+ result[:worst_table] = plan_node["Relation Name"]
639
+ end
640
+ end
641
+
642
+ if plan_node["Plans"]
643
+ plan_node["Plans"].each { |child| cardinality_accuracy(child, result) }
644
+ end
645
+
646
+ result[:worst_ratio] > 1.0 ? result : nil
647
+ end
648
+
523
649
  def find_hotspots(plan_node, hotspots = [])
524
650
  return hotspots unless @analyze
525
651
 
@@ -35,6 +35,7 @@
35
35
  <nav class="flex items-center space-x-3 ml-6">
36
36
  <%= link_to "Queries", queries_path, class: "text-sm font-medium #{'text-blue-600' if controller_name == 'queries'} #{'text-gray-500 hover:text-gray-700' unless controller_name == 'queries'} transition-colors" %>
37
37
  <%= link_to "Schema", schema_index_path, class: "text-sm font-medium #{'text-blue-600' if controller_name == 'schema'} #{'text-gray-500 hover:text-gray-700' unless controller_name == 'schema'} transition-colors" %>
38
+ <%= link_to "Console", console_index_path, class: "text-sm font-medium #{'text-blue-600' if controller_name == 'console'} #{'text-gray-500 hover:text-gray-700' unless controller_name == 'console'} transition-colors" %>
38
39
  </nav>
39
40
  </div>
40
41
  <div class="flex items-center space-x-4">