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
data/config/routes.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RailsDbInspector::Engine.routes.draw do
|
|
4
|
+
root to: "queries#index"
|
|
5
|
+
|
|
6
|
+
resources :queries, only: %i[index show] do
|
|
7
|
+
member do
|
|
8
|
+
get :explain
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
collection do
|
|
12
|
+
post :clear
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
get "schema", to: "schema#index", as: :schema_index
|
|
17
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDbInspector
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :enabled
|
|
6
|
+
attr_accessor :max_queries
|
|
7
|
+
attr_accessor :allow_explain_analyze
|
|
8
|
+
attr_accessor :show_widget
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@enabled = true
|
|
12
|
+
@max_queries = 2_000
|
|
13
|
+
@allow_explain_analyze = false
|
|
14
|
+
@show_widget = true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDbInspector
|
|
4
|
+
class DevWidgetMiddleware
|
|
5
|
+
def initialize(app)
|
|
6
|
+
@app = app
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(env)
|
|
10
|
+
status, headers, response = @app.call(env)
|
|
11
|
+
|
|
12
|
+
# Only inject into HTML responses in development
|
|
13
|
+
return [ status, headers, response ] unless injectable?(status, headers, env)
|
|
14
|
+
|
|
15
|
+
body = +""
|
|
16
|
+
response.each { |part| body << part }
|
|
17
|
+
response.close if response.respond_to?(:close)
|
|
18
|
+
|
|
19
|
+
# Find the mount path for the engine
|
|
20
|
+
mount_path = find_mount_path
|
|
21
|
+
|
|
22
|
+
if body.include?("</body>") && mount_path
|
|
23
|
+
widget_html = render_widget(mount_path)
|
|
24
|
+
body.sub!("</body>", "#{widget_html}\n</body>")
|
|
25
|
+
headers["Content-Length"] = body.bytesize.to_s
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
[ status, headers, [ body ] ]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def injectable?(status, headers, env)
|
|
34
|
+
return false unless status == 200
|
|
35
|
+
return false unless headers["Content-Type"]&.include?("text/html")
|
|
36
|
+
|
|
37
|
+
# Don't inject into the engine's own pages
|
|
38
|
+
mount_path = find_mount_path
|
|
39
|
+
return false if mount_path && env["PATH_INFO"]&.start_with?(mount_path)
|
|
40
|
+
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def find_mount_path
|
|
45
|
+
@mount_path ||= begin
|
|
46
|
+
Rails.application.routes.routes.each do |route|
|
|
47
|
+
if route.app.respond_to?(:app) && route.app.app == RailsDbInspector::Engine
|
|
48
|
+
return "/" + route.path.spec.to_s.sub(/\(.*\)/, "").gsub(%r{^/|/$}, "")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def render_widget(mount_path)
|
|
56
|
+
queries_url = "#{mount_path}"
|
|
57
|
+
schema_url = "#{mount_path}/schema"
|
|
58
|
+
|
|
59
|
+
<<~HTML
|
|
60
|
+
<!-- Rails DB Inspector Dev Widget -->
|
|
61
|
+
<div id="rdi-widget" style="
|
|
62
|
+
position: fixed;
|
|
63
|
+
bottom: 16px;
|
|
64
|
+
right: 16px;
|
|
65
|
+
z-index: 99999;
|
|
66
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
67
|
+
">
|
|
68
|
+
<div id="rdi-panel" style="
|
|
69
|
+
display: none;
|
|
70
|
+
background: #1f2937;
|
|
71
|
+
border-radius: 12px;
|
|
72
|
+
padding: 12px;
|
|
73
|
+
margin-bottom: 8px;
|
|
74
|
+
box-shadow: 0 10px 25px rgba(0,0,0,0.3);
|
|
75
|
+
min-width: 200px;
|
|
76
|
+
">
|
|
77
|
+
<div style="color: #9ca3af; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; padding: 0 4px;">
|
|
78
|
+
DB Inspector
|
|
79
|
+
</div>
|
|
80
|
+
<a href="#{queries_url}" target="_blank" rel="noopener" style="
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
padding: 8px 12px;
|
|
84
|
+
color: #e5e7eb;
|
|
85
|
+
text-decoration: none;
|
|
86
|
+
font-size: 13px;
|
|
87
|
+
font-weight: 500;
|
|
88
|
+
border-radius: 8px;
|
|
89
|
+
margin-bottom: 4px;
|
|
90
|
+
transition: background 0.15s;
|
|
91
|
+
" onmouseover="this.style.background='#374151'" onmouseout="this.style.background='transparent'">
|
|
92
|
+
<span style="margin-right: 8px; font-size: 16px;">🔍</span>
|
|
93
|
+
Query Monitor
|
|
94
|
+
</a>
|
|
95
|
+
<a href="#{schema_url}" target="_blank" rel="noopener" style="
|
|
96
|
+
display: flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
padding: 8px 12px;
|
|
99
|
+
color: #e5e7eb;
|
|
100
|
+
text-decoration: none;
|
|
101
|
+
font-size: 13px;
|
|
102
|
+
font-weight: 500;
|
|
103
|
+
border-radius: 8px;
|
|
104
|
+
transition: background 0.15s;
|
|
105
|
+
" onmouseover="this.style.background='#374151'" onmouseout="this.style.background='transparent'">
|
|
106
|
+
<span style="margin-right: 8px; font-size: 16px;">🗄️</span>
|
|
107
|
+
Schema Visualization
|
|
108
|
+
</a>
|
|
109
|
+
</div>
|
|
110
|
+
<button onclick="
|
|
111
|
+
var panel = document.getElementById('rdi-panel');
|
|
112
|
+
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
|
113
|
+
" style="
|
|
114
|
+
width: 48px;
|
|
115
|
+
height: 48px;
|
|
116
|
+
border-radius: 50%;
|
|
117
|
+
background: #2563eb;
|
|
118
|
+
border: none;
|
|
119
|
+
color: white;
|
|
120
|
+
font-size: 20px;
|
|
121
|
+
cursor: pointer;
|
|
122
|
+
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
|
|
123
|
+
display: flex;
|
|
124
|
+
align-items: center;
|
|
125
|
+
justify-content: center;
|
|
126
|
+
transition: transform 0.15s, box-shadow 0.15s;
|
|
127
|
+
margin-left: auto;
|
|
128
|
+
" onmouseover="this.style.transform='scale(1.1)';this.style.boxShadow='0 6px 16px rgba(37,99,235,0.5)'" onmouseout="this.style.transform='scale(1)';this.style.boxShadow='0 4px 12px rgba(37,99,235,0.4)'">
|
|
129
|
+
🛢️
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
<script>
|
|
133
|
+
document.addEventListener('click', function(e) {
|
|
134
|
+
var widget = document.getElementById('rdi-widget');
|
|
135
|
+
var panel = document.getElementById('rdi-panel');
|
|
136
|
+
if (panel && widget && !widget.contains(e.target)) {
|
|
137
|
+
panel.style.display = 'none';
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
</script>
|
|
141
|
+
<!-- /Rails DB Inspector Dev Widget -->
|
|
142
|
+
HTML
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "dev_widget_middleware"
|
|
4
|
+
|
|
5
|
+
module RailsDbInspector
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace RailsDbInspector
|
|
8
|
+
|
|
9
|
+
initializer "rails_db_inspector.subscribe_sql" do
|
|
10
|
+
next unless RailsDbInspector.configuration.enabled
|
|
11
|
+
RailsDbInspector::SqlSubscriber.install!
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
initializer "rails_db_inspector.dev_widget" do |app|
|
|
15
|
+
next unless RailsDbInspector.configuration.enabled
|
|
16
|
+
next unless RailsDbInspector.configuration.show_widget
|
|
17
|
+
next unless Rails.env.development?
|
|
18
|
+
|
|
19
|
+
app.middleware.use RailsDbInspector::DevWidgetMiddleware
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDbInspector
|
|
4
|
+
class Explain
|
|
5
|
+
class MySql
|
|
6
|
+
def initialize(connection)
|
|
7
|
+
@connection = connection
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def explain(sql, analyze: false)
|
|
11
|
+
# Strip any existing EXPLAIN prefix to prevent doubling
|
|
12
|
+
clean_sql = sql.sub(/\A\s*EXPLAIN\s*(ANALYZE)?\s*/i, "")
|
|
13
|
+
|
|
14
|
+
statement =
|
|
15
|
+
if analyze
|
|
16
|
+
RailsDbInspector::Explain.select_only!(clean_sql)
|
|
17
|
+
"EXPLAIN ANALYZE #{clean_sql}"
|
|
18
|
+
else
|
|
19
|
+
"EXPLAIN #{clean_sql}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
result = @connection.exec_query(statement)
|
|
23
|
+
|
|
24
|
+
{ adapter: "mysql", analyze: analyze, columns: result.columns, rows: result.rows }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RailsDbInspector
|
|
6
|
+
class Explain
|
|
7
|
+
class Postgres
|
|
8
|
+
def initialize(connection)
|
|
9
|
+
@connection = connection
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def explain(sql, analyze: false)
|
|
13
|
+
# Strip any existing EXPLAIN prefix to prevent doubling
|
|
14
|
+
clean_sql = sql.sub(/\A\s*EXPLAIN\s*(\([^)]*\))?\s*/i, "")
|
|
15
|
+
|
|
16
|
+
statement =
|
|
17
|
+
if analyze
|
|
18
|
+
RailsDbInspector::Explain.select_only!(clean_sql)
|
|
19
|
+
"EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT JSON) #{clean_sql}"
|
|
20
|
+
else
|
|
21
|
+
"EXPLAIN (FORMAT JSON) #{clean_sql}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
result = @connection.exec_query(statement)
|
|
25
|
+
raw = result.rows.dig(0, 0)
|
|
26
|
+
plan = raw.is_a?(String) ? JSON.parse(raw) : raw
|
|
27
|
+
|
|
28
|
+
{ adapter: "postgres", analyze: analyze, plan: plan }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDbInspector
|
|
4
|
+
class Explain
|
|
5
|
+
class UnsupportedAdapter < StandardError; end
|
|
6
|
+
class DangerousQuery < StandardError; end
|
|
7
|
+
|
|
8
|
+
def self.for_connection(connection)
|
|
9
|
+
adapter = connection.adapter_name.to_s.downcase
|
|
10
|
+
|
|
11
|
+
case adapter
|
|
12
|
+
when /postgres/
|
|
13
|
+
RailsDbInspector::Explain::Postgres.new(connection)
|
|
14
|
+
when /mysql/
|
|
15
|
+
RailsDbInspector::Explain::MySql.new(connection)
|
|
16
|
+
else
|
|
17
|
+
raise UnsupportedAdapter, "Unsupported adapter: #{connection.adapter_name}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.select_only!(sql)
|
|
22
|
+
return if sql.strip.match?(/\ASELECT\b/i)
|
|
23
|
+
|
|
24
|
+
raise DangerousQuery, "Only SELECT is allowed for EXPLAIN ANALYZE by default"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "singleton"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "thread"
|
|
6
|
+
|
|
7
|
+
module RailsDbInspector
|
|
8
|
+
class QueryStore
|
|
9
|
+
include Singleton
|
|
10
|
+
|
|
11
|
+
Query = Struct.new(
|
|
12
|
+
:id,
|
|
13
|
+
:sql,
|
|
14
|
+
:name,
|
|
15
|
+
:binds,
|
|
16
|
+
:duration_ms,
|
|
17
|
+
:connection_id,
|
|
18
|
+
:timestamp,
|
|
19
|
+
keyword_init: true
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
@queries = []
|
|
25
|
+
@by_id = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def add(sql:, name:, binds:, duration_ms:, connection_id:, timestamp:)
|
|
29
|
+
q = Query.new(
|
|
30
|
+
id: SecureRandom.hex(8),
|
|
31
|
+
sql: sql,
|
|
32
|
+
name: name,
|
|
33
|
+
binds: normalize_binds(binds),
|
|
34
|
+
duration_ms: duration_ms.to_f,
|
|
35
|
+
connection_id: connection_id,
|
|
36
|
+
timestamp: timestamp
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
@queries << q
|
|
41
|
+
@by_id[q.id] = q
|
|
42
|
+
trim!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
q
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def all
|
|
49
|
+
@mutex.synchronize { @queries.dup }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def find(id)
|
|
53
|
+
@mutex.synchronize { @by_id[id] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def clear!
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
@queries.clear
|
|
59
|
+
@by_id.clear
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def trim!
|
|
66
|
+
max = RailsDbInspector.configuration.max_queries.to_i
|
|
67
|
+
return if max <= 0
|
|
68
|
+
return if @queries.length <= max
|
|
69
|
+
|
|
70
|
+
drop_count = @queries.length - max
|
|
71
|
+
dropped = @queries.shift(drop_count)
|
|
72
|
+
dropped.each { |q| @by_id.delete(q.id) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def normalize_binds(binds)
|
|
76
|
+
return [] unless binds.is_a?(Array)
|
|
77
|
+
|
|
78
|
+
binds.map do |b|
|
|
79
|
+
if b.respond_to?(:name) && b.respond_to?(:value_before_type_cast)
|
|
80
|
+
{ name: b.name, value: b.value_before_type_cast, type: b.type&.type }
|
|
81
|
+
elsif b.is_a?(Array) && b.length == 2
|
|
82
|
+
{ name: b[0].to_s, value: b[1], type: nil }
|
|
83
|
+
else
|
|
84
|
+
{ name: nil, value: b.to_s, type: nil }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDbInspector
|
|
4
|
+
class SchemaInspector
|
|
5
|
+
attr_reader :connection
|
|
6
|
+
|
|
7
|
+
def initialize(connection = ActiveRecord::Base.connection)
|
|
8
|
+
@connection = connection
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
IGNORED_TABLES = %w[schema_migrations ar_internal_metadata].freeze
|
|
12
|
+
|
|
13
|
+
# Returns a hash of table_name => { columns:, indexes:, foreign_keys: }
|
|
14
|
+
def introspect
|
|
15
|
+
tables = connection.tables.sort - IGNORED_TABLES
|
|
16
|
+
schema = {}
|
|
17
|
+
|
|
18
|
+
tables.each do |table|
|
|
19
|
+
columns = introspect_columns(table)
|
|
20
|
+
indexes = introspect_indexes(table)
|
|
21
|
+
schema[table] = {
|
|
22
|
+
columns: columns,
|
|
23
|
+
indexes: indexes,
|
|
24
|
+
foreign_keys: introspect_foreign_keys(table),
|
|
25
|
+
primary_key: introspect_primary_key(table),
|
|
26
|
+
row_count: safe_row_count(table),
|
|
27
|
+
associations: introspect_associations(table),
|
|
28
|
+
missing_indexes: detect_missing_indexes(columns, indexes),
|
|
29
|
+
polymorphic_columns: detect_polymorphic_columns(columns)
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
schema
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns relationships between tables for visualization
|
|
37
|
+
def relationships
|
|
38
|
+
rels = []
|
|
39
|
+
|
|
40
|
+
(connection.tables.sort - IGNORED_TABLES).each do |table|
|
|
41
|
+
# Foreign key-based relationships
|
|
42
|
+
if connection.respond_to?(:foreign_keys)
|
|
43
|
+
connection.foreign_keys(table).each do |fk|
|
|
44
|
+
rels << {
|
|
45
|
+
from_table: table,
|
|
46
|
+
from_column: fk.column,
|
|
47
|
+
to_table: fk.to_table,
|
|
48
|
+
to_column: fk.primary_key || "id",
|
|
49
|
+
type: :foreign_key
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Convention-based relationships (belongs_to via _id columns)
|
|
55
|
+
connection.columns(table).each do |col|
|
|
56
|
+
next unless col.name.end_with?("_id")
|
|
57
|
+
|
|
58
|
+
referenced_table = col.name.sub(/_id\z/, "").pluralize
|
|
59
|
+
next unless connection.tables.include?(referenced_table)
|
|
60
|
+
|
|
61
|
+
# Skip if already covered by a foreign key
|
|
62
|
+
already_covered = rels.any? do |r|
|
|
63
|
+
r[:from_table] == table && r[:from_column] == col.name
|
|
64
|
+
end
|
|
65
|
+
next if already_covered
|
|
66
|
+
|
|
67
|
+
rels << {
|
|
68
|
+
from_table: table,
|
|
69
|
+
from_column: col.name,
|
|
70
|
+
to_table: referenced_table,
|
|
71
|
+
to_column: "id",
|
|
72
|
+
type: :convention
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
rels
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def introspect_columns(table)
|
|
83
|
+
connection.columns(table).map do |col|
|
|
84
|
+
{
|
|
85
|
+
name: col.name,
|
|
86
|
+
type: col.sql_type,
|
|
87
|
+
nullable: col.null,
|
|
88
|
+
default: col.default
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def introspect_indexes(table)
|
|
94
|
+
connection.indexes(table).map do |idx|
|
|
95
|
+
{
|
|
96
|
+
name: idx.name,
|
|
97
|
+
columns: idx.columns,
|
|
98
|
+
unique: idx.unique
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def introspect_foreign_keys(table)
|
|
104
|
+
return [] unless connection.respond_to?(:foreign_keys)
|
|
105
|
+
|
|
106
|
+
connection.foreign_keys(table).map do |fk|
|
|
107
|
+
{
|
|
108
|
+
column: fk.column,
|
|
109
|
+
to_table: fk.to_table,
|
|
110
|
+
primary_key: fk.primary_key || "id",
|
|
111
|
+
name: fk.name
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def introspect_primary_key(table)
|
|
117
|
+
connection.primary_key(table)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def safe_row_count(table)
|
|
121
|
+
quoted = connection.quote_table_name(table)
|
|
122
|
+
result = connection.select_value("SELECT COUNT(*) FROM #{quoted}")
|
|
123
|
+
result.to_i
|
|
124
|
+
rescue StandardError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def introspect_associations(table)
|
|
129
|
+
model = find_model_for_table(table)
|
|
130
|
+
return [] unless model
|
|
131
|
+
|
|
132
|
+
model.reflect_on_all_associations.map do |assoc|
|
|
133
|
+
target_table = begin
|
|
134
|
+
assoc.klass.table_name
|
|
135
|
+
rescue StandardError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
{
|
|
140
|
+
name: assoc.name.to_s,
|
|
141
|
+
macro: assoc.macro.to_s,
|
|
142
|
+
target_table: target_table,
|
|
143
|
+
foreign_key: assoc.foreign_key.to_s,
|
|
144
|
+
through: assoc.options[:through]&.to_s
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
rescue StandardError
|
|
148
|
+
[]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Detect _id columns that lack a corresponding index
|
|
152
|
+
def detect_missing_indexes(columns, indexes)
|
|
153
|
+
indexed_columns = indexes.flat_map { |idx| idx[:columns] }.to_set
|
|
154
|
+
|
|
155
|
+
columns
|
|
156
|
+
.select { |col| col[:name].end_with?("_id") }
|
|
157
|
+
.reject { |col| indexed_columns.include?(col[:name]) }
|
|
158
|
+
.map { |col| col[:name] }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Detect polymorphic column pairs (*_type + *_id)
|
|
162
|
+
def detect_polymorphic_columns(columns)
|
|
163
|
+
col_names = columns.map { |c| c[:name] }.to_set
|
|
164
|
+
polymorphics = []
|
|
165
|
+
|
|
166
|
+
columns.each do |col|
|
|
167
|
+
next unless col[:name].end_with?("_type")
|
|
168
|
+
|
|
169
|
+
base = col[:name].sub(/_type\z/, "")
|
|
170
|
+
id_col = "#{base}_id"
|
|
171
|
+
next unless col_names.include?(id_col)
|
|
172
|
+
|
|
173
|
+
polymorphics << {
|
|
174
|
+
name: base,
|
|
175
|
+
type_column: col[:name],
|
|
176
|
+
id_column: id_col
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
polymorphics
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def find_model_for_table(table)
|
|
184
|
+
# Force-load all models so descendants are populated
|
|
185
|
+
eager_load_models!
|
|
186
|
+
|
|
187
|
+
ActiveRecord::Base.descendants.detect do |klass|
|
|
188
|
+
klass.table_name == table && !klass.abstract_class?
|
|
189
|
+
rescue StandardError
|
|
190
|
+
false
|
|
191
|
+
end
|
|
192
|
+
rescue StandardError
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def eager_load_models!
|
|
197
|
+
return if @models_loaded
|
|
198
|
+
@models_loaded = true
|
|
199
|
+
|
|
200
|
+
# Use Zeitwerk autoloader to load all models
|
|
201
|
+
model_paths = Rails.application.config.paths["app/models"].to_a
|
|
202
|
+
model_paths.each do |dir|
|
|
203
|
+
Dir.glob("#{dir}/**/*.rb").sort.each do |file|
|
|
204
|
+
begin
|
|
205
|
+
require file
|
|
206
|
+
rescue StandardError, LoadError
|
|
207
|
+
# Skip models that fail to load
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Fallback: eager load the whole app if no descendants found
|
|
213
|
+
if ActiveRecord::Base.descendants.reject { |k| k.abstract_class? rescue true }.empty?
|
|
214
|
+
begin
|
|
215
|
+
Rails.application.eager_load!
|
|
216
|
+
rescue StandardError
|
|
217
|
+
# ignore
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDbInspector
|
|
4
|
+
class SqlSubscriber
|
|
5
|
+
IGNORED_NAMES = [
|
|
6
|
+
"SCHEMA",
|
|
7
|
+
"TRANSACTION",
|
|
8
|
+
"ActiveRecord::SchemaMigration Load",
|
|
9
|
+
"ActiveRecord::InternalMetadata Load"
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
def self.install!
|
|
13
|
+
return if @installed
|
|
14
|
+
|
|
15
|
+
@installed = true
|
|
16
|
+
RailsDbInspector::QueryStore.instance
|
|
17
|
+
|
|
18
|
+
ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
|
|
19
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
20
|
+
payload = event.payload
|
|
21
|
+
|
|
22
|
+
name = payload[:name].to_s
|
|
23
|
+
sql = payload[:sql].to_s
|
|
24
|
+
|
|
25
|
+
next if sql.strip.empty?
|
|
26
|
+
next if IGNORED_NAMES.include?(name)
|
|
27
|
+
next if sql =~ /\A(?:BEGIN|COMMIT|ROLLBACK)\b/i
|
|
28
|
+
next if sql =~ /\A\s*EXPLAIN\b/i
|
|
29
|
+
next if payload[:cached]
|
|
30
|
+
|
|
31
|
+
RailsDbInspector::QueryStore.instance.add(
|
|
32
|
+
sql: sql,
|
|
33
|
+
name: name,
|
|
34
|
+
binds: payload[:binds],
|
|
35
|
+
duration_ms: event.duration,
|
|
36
|
+
connection_id: payload[:connection_id],
|
|
37
|
+
timestamp: (event.time.is_a?(Time) ? event.time : Time.at(event.time.to_f))
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rails_db_inspector/version"
|
|
4
|
+
require_relative "rails_db_inspector/engine"
|
|
5
|
+
require_relative "rails_db_inspector/configuration"
|
|
6
|
+
require_relative "rails_db_inspector/query_store"
|
|
7
|
+
require_relative "rails_db_inspector/sql_subscriber"
|
|
8
|
+
require_relative "rails_db_inspector/explain"
|
|
9
|
+
require_relative "rails_db_inspector/explain/postgres"
|
|
10
|
+
require_relative "rails_db_inspector/explain/my_sql"
|
|
11
|
+
require_relative "rails_db_inspector/schema_inspector"
|
|
12
|
+
|
|
13
|
+
module RailsDbInspector
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def configuration
|
|
18
|
+
@configuration ||= Configuration.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def configure
|
|
22
|
+
yield(configuration)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|