rails_visualizer 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/CHANGELOG.md +36 -0
- data/LICENSE.txt +21 -0
- data/README.md +509 -0
- data/lib/rails_visualizer/assets/dist/index.html +76 -0
- data/lib/rails_visualizer/configuration.rb +35 -0
- data/lib/rails_visualizer/controllers_inspector.rb +200 -0
- data/lib/rails_visualizer/gems_inspector.rb +105 -0
- data/lib/rails_visualizer/introspector.rb +191 -0
- data/lib/rails_visualizer/jobs_inspector.rb +120 -0
- data/lib/rails_visualizer/mailers_inspector.rb +126 -0
- data/lib/rails_visualizer/migrations_inspector.rb +68 -0
- data/lib/rails_visualizer/path_helper.rb +29 -0
- data/lib/rails_visualizer/railtie.rb +9 -0
- data/lib/rails_visualizer/renderer.rb +31 -0
- data/lib/rails_visualizer/routes_inspector.rb +124 -0
- data/lib/rails_visualizer/schema/cache.rb +234 -0
- data/lib/rails_visualizer/schema/index_inspector.rb +43 -0
- data/lib/rails_visualizer/schema/model_inspector.rb +245 -0
- data/lib/rails_visualizer/serializer.rb +124 -0
- data/lib/rails_visualizer/version.rb +5 -0
- data/lib/rails_visualizer.rb +22 -0
- data/lib/tasks/rails_visualizer.rake +59 -0
- metadata +76 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsVisualizer
|
|
4
|
+
class MailersInspector
|
|
5
|
+
include RailsVisualizer::PathHelper
|
|
6
|
+
|
|
7
|
+
FRAMEWORK_NAMESPACES = %w[
|
|
8
|
+
ActionMailer::
|
|
9
|
+
Devise::
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(excluded: [])
|
|
13
|
+
@excluded_set = excluded.to_set
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
return [] unless defined?(ActionMailer::Base)
|
|
18
|
+
|
|
19
|
+
mailers = ActionMailer::Base
|
|
20
|
+
.descendants
|
|
21
|
+
.reject { |m| m.name.nil? || framework_class?(m.name) || @excluded_set.include?(m.name) }
|
|
22
|
+
|
|
23
|
+
superclass_names = mailers.to_set { |m| m.superclass.name }
|
|
24
|
+
|
|
25
|
+
mailers
|
|
26
|
+
.map { |m| serialize_mailer(m, superclass_names) }
|
|
27
|
+
.sort_by { |m| m[:name] }
|
|
28
|
+
rescue StandardError
|
|
29
|
+
[]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def serialize_mailer(mailer, superclass_names)
|
|
35
|
+
own = own_action_methods(mailer)
|
|
36
|
+
{
|
|
37
|
+
name: mailer.name,
|
|
38
|
+
superclass: mailer.superclass.name,
|
|
39
|
+
abstract: own.empty? && superclass_names.include?(mailer.name),
|
|
40
|
+
file_path: source_path_for(mailer),
|
|
41
|
+
actions: own,
|
|
42
|
+
inherited_actions: inherited_action_methods(mailer, own),
|
|
43
|
+
default_from: extract_default_param(mailer, :from),
|
|
44
|
+
default_reply_to: extract_default_param(mailer, :reply_to),
|
|
45
|
+
layout: extract_layout(mailer),
|
|
46
|
+
callbacks: extract_callbacks(mailer)
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def own_action_methods(mailer)
|
|
51
|
+
action_methods = mailer.action_methods.to_set
|
|
52
|
+
mailer.public_instance_methods(false)
|
|
53
|
+
.map(&:to_s)
|
|
54
|
+
.select { |m| action_methods.include?(m) }
|
|
55
|
+
.sort
|
|
56
|
+
rescue StandardError
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns inherited email methods grouped by the ancestor class that defines them.
|
|
61
|
+
# Walks up to (but not including) ActionMailer::Base; stops at framework boundaries.
|
|
62
|
+
def inherited_action_methods(mailer, own_methods)
|
|
63
|
+
action_set = mailer.action_methods.to_set
|
|
64
|
+
already_seen = own_methods.to_set
|
|
65
|
+
result = []
|
|
66
|
+
|
|
67
|
+
user_ancestors(mailer).each { |a| accumulate_ancestor_group(a, action_set, already_seen, result) }
|
|
68
|
+
|
|
69
|
+
result
|
|
70
|
+
rescue StandardError
|
|
71
|
+
[]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def accumulate_ancestor_group(ancestor, action_set, already_seen, result)
|
|
75
|
+
ancestor_methods = ancestor.public_instance_methods(false).map(&:to_s)
|
|
76
|
+
relevant = ancestor_methods.select { |m| action_set.include?(m) && already_seen.exclude?(m) }
|
|
77
|
+
return if relevant.empty?
|
|
78
|
+
|
|
79
|
+
result << { defined_in: ancestor.name || ancestor.to_s, methods: relevant.sort }
|
|
80
|
+
already_seen.merge(relevant)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Ancestors between the mailer itself and ActionMailer::Base, excluding framework modules.
|
|
84
|
+
def user_ancestors(mailer)
|
|
85
|
+
mailer.ancestors
|
|
86
|
+
.drop_while { |a| a == mailer }
|
|
87
|
+
.take_while { |a| a != ActionMailer::Base && !framework_ancestor?(a) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def extract_default_param(mailer, key)
|
|
91
|
+
params = mailer.default_params
|
|
92
|
+
params[key] || params[key.to_s]
|
|
93
|
+
rescue StandardError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def extract_layout(mailer)
|
|
98
|
+
layout = mailer._layout
|
|
99
|
+
layout.is_a?(String) ? layout.presence : nil
|
|
100
|
+
rescue StandardError
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def extract_callbacks(mailer)
|
|
105
|
+
chain = mailer.send(:_process_action_callbacks).to_a
|
|
106
|
+
result = { before: [], after: [], around: [] }
|
|
107
|
+
chain.select { |cb| cb.filter.is_a?(Symbol) }.each do |cb|
|
|
108
|
+
result[cb.kind] << cb.filter.to_s
|
|
109
|
+
end
|
|
110
|
+
result
|
|
111
|
+
rescue StandardError
|
|
112
|
+
{ before: [], after: [], around: [] }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def framework_ancestor?(ancestor)
|
|
116
|
+
return true unless ancestor.respond_to?(:name)
|
|
117
|
+
|
|
118
|
+
name = ancestor.name
|
|
119
|
+
name.nil? || FRAMEWORK_NAMESPACES.any? { |ns| name.start_with?(ns) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def framework_class?(name)
|
|
123
|
+
FRAMEWORK_NAMESPACES.any? { |ns| name.start_with?(ns) }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsVisualizer
|
|
4
|
+
class MigrationsInspector
|
|
5
|
+
include RailsVisualizer::PathHelper
|
|
6
|
+
|
|
7
|
+
def call
|
|
8
|
+
run_versions = fetch_run_versions
|
|
9
|
+
migrations = build_migrations(run_versions)
|
|
10
|
+
summarize(migrations, run_versions)
|
|
11
|
+
rescue StandardError
|
|
12
|
+
{ migrations: [], current_version: nil, total_count: 0, pending_count: 0, applied_count: 0 }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def build_migrations(run_versions)
|
|
18
|
+
migration_files.map { |file| build_entry(file, run_versions) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def build_entry(file, run_versions)
|
|
22
|
+
version = File.basename(file)[/^\d+/]
|
|
23
|
+
raw_name = File.basename(file, '.rb').sub(/^\d+_/, '')
|
|
24
|
+
{
|
|
25
|
+
version: version,
|
|
26
|
+
name: raw_name,
|
|
27
|
+
human_name: humanize(raw_name),
|
|
28
|
+
status: run_versions.include?(version) ? 'up' : 'down',
|
|
29
|
+
file_path: relative_to_root(file.to_s),
|
|
30
|
+
ran_at: nil
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def summarize(migrations, run_versions)
|
|
35
|
+
{
|
|
36
|
+
migrations: migrations,
|
|
37
|
+
current_version: run_versions.max,
|
|
38
|
+
total_count: migrations.size,
|
|
39
|
+
pending_count: migrations.count { |m| m[:status] == 'down' },
|
|
40
|
+
applied_count: migrations.count { |m| m[:status] == 'up' }
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Direct SQL is the most reliable way to read schema_migrations across
|
|
45
|
+
# all Rails versions (7.0, 7.1, 8.x) without hitting API breakages in
|
|
46
|
+
# the ActiveRecord::SchemaMigration class hierarchy.
|
|
47
|
+
def fetch_run_versions
|
|
48
|
+
conn = ActiveRecord::Base.connection
|
|
49
|
+
return Set.new unless conn.table_exists?('schema_migrations')
|
|
50
|
+
|
|
51
|
+
Set.new(conn.select_values('SELECT version FROM schema_migrations').map(&:to_s))
|
|
52
|
+
rescue StandardError
|
|
53
|
+
Set.new
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def migration_files
|
|
57
|
+
return [] unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
58
|
+
|
|
59
|
+
Rails.root.glob('db/migrate/*.rb').sort
|
|
60
|
+
rescue StandardError
|
|
61
|
+
[]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def humanize(name)
|
|
65
|
+
name.tr('_', ' ').split.map(&:capitalize).join(' ')
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsVisualizer
|
|
4
|
+
# Shared helper for resolving the actual source file of a Ruby constant.
|
|
5
|
+
# Uses Module.const_source_location (Ruby 2.7+) and returns a path
|
|
6
|
+
# relative to Rails.root so it is portable across machines.
|
|
7
|
+
module PathHelper
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def source_path_for(klass)
|
|
11
|
+
absolute = Module.const_source_location(klass.name)&.first
|
|
12
|
+
return nil if absolute.blank?
|
|
13
|
+
|
|
14
|
+
relative_to_root(absolute)
|
|
15
|
+
rescue StandardError
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def relative_to_root(absolute)
|
|
20
|
+
prefix = "#{app_root}/"
|
|
21
|
+
absolute.start_with?(prefix) ? absolute.delete_prefix(prefix) : absolute
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Memoised per-instance so Rails.root is evaluated once, not on every call.
|
|
25
|
+
def app_root
|
|
26
|
+
@app_root ||= (defined?(Rails) && Rails.respond_to?(:root) ? Rails.root.to_s : Dir.pwd)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsVisualizer
|
|
4
|
+
class Renderer
|
|
5
|
+
PLACEHOLDER = '<script id="rails-visualizer-data"></script>'
|
|
6
|
+
ASSET_PATH = File.join(__dir__, 'assets', 'dist')
|
|
7
|
+
|
|
8
|
+
def initialize(json_data)
|
|
9
|
+
@json_data = json_data
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
template = read_template
|
|
14
|
+
template.sub(PLACEHOLDER) { data_script }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def read_template
|
|
20
|
+
File.read(template_path)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def template_path
|
|
24
|
+
File.join(ASSET_PATH, 'index.html')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def data_script
|
|
28
|
+
%(<script id="rails-visualizer-data">window.__RAILS_VISUALIZER_DATA__ = #{@json_data};</script>)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsVisualizer
|
|
4
|
+
class RoutesInspector
|
|
5
|
+
# Controllers served by Rails framework / engines — not user-defined code.
|
|
6
|
+
INTERNAL_PREFIXES = %w[
|
|
7
|
+
rails/
|
|
8
|
+
active_storage
|
|
9
|
+
action_mailbox
|
|
10
|
+
action_text
|
|
11
|
+
turbo/
|
|
12
|
+
cable/
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(excluded_paths: [])
|
|
16
|
+
@excluded_paths = excluded_paths
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
Rails.application.routes.routes
|
|
21
|
+
.select { |r| r.defaults[:controller].present? && r.verb.present? }
|
|
22
|
+
.reject { |r| excluded_path?(r.path.spec.to_s) }
|
|
23
|
+
.map { |r| serialize_route(r) }
|
|
24
|
+
rescue StandardError
|
|
25
|
+
[]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def serialize_route(route) # rubocop:disable Metrics/AbcSize
|
|
31
|
+
controller = route.defaults[:controller].to_s
|
|
32
|
+
action = route.defaults[:action].to_s
|
|
33
|
+
path = route.path.spec.to_s
|
|
34
|
+
is_internal = internal?(controller)
|
|
35
|
+
ctrl_file = controller_file_map[controller]
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
verb: route.verb.to_s,
|
|
39
|
+
path: path,
|
|
40
|
+
controller: controller,
|
|
41
|
+
action: action,
|
|
42
|
+
name: route.name,
|
|
43
|
+
namespace: extract_namespace(controller),
|
|
44
|
+
internal: is_internal,
|
|
45
|
+
orphaned: !is_internal && controller.present? && ctrl_file.nil?,
|
|
46
|
+
controller_file_path: ctrl_file,
|
|
47
|
+
constraints: serialize_constraints(route.constraints)
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Builds a hash of controller_path => absolute_file_path by scanning every
|
|
52
|
+
# configured app/controllers directory across the main app and all engines/
|
|
53
|
+
# railties. Filesystem-based — works regardless of class-loading state and
|
|
54
|
+
# correctly handles monorepo component directories.
|
|
55
|
+
def controller_file_map
|
|
56
|
+
@controller_file_map ||= begin
|
|
57
|
+
dirs = controller_dirs
|
|
58
|
+
dirs.each_with_object({}) do |dir, map|
|
|
59
|
+
Dir.glob(File.join(dir, '**', '*_controller.rb')).each do |file|
|
|
60
|
+
# Derive the Rails controller path from the file path, e.g.:
|
|
61
|
+
# ".../app/controllers/project_area/commitments_controller.rb"
|
|
62
|
+
# → "project_area/commitments"
|
|
63
|
+
rel = file.delete_prefix("#{dir}/").delete_suffix('_controller.rb')
|
|
64
|
+
map[rel] ||= file # first match wins; avoids overwriting earlier finds
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Collects all directories that Rails treats as controller source dirs,
|
|
71
|
+
# covering the main app, all mounted engines/railties, and any paths
|
|
72
|
+
# manually added to autoload_paths / eager_load_paths.
|
|
73
|
+
def controller_dirs
|
|
74
|
+
dirs = Set.new
|
|
75
|
+
add_app_controller_dirs(dirs)
|
|
76
|
+
add_railtie_controller_dirs(dirs)
|
|
77
|
+
add_autoload_controller_dirs(dirs)
|
|
78
|
+
dirs
|
|
79
|
+
rescue StandardError
|
|
80
|
+
Set.new
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def add_app_controller_dirs(dirs)
|
|
84
|
+
Array(Rails.application.paths['app/controllers']&.existent).each { |d| dirs.add(d.to_s) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def add_railtie_controller_dirs(dirs)
|
|
88
|
+
Rails.application.railties.each do |railtie|
|
|
89
|
+
next unless railtie.respond_to?(:paths)
|
|
90
|
+
|
|
91
|
+
Array(railtie.paths['app/controllers']&.existent).each { |d| dirs.add(d.to_s) }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def add_autoload_controller_dirs(dirs)
|
|
96
|
+
# Scan ALL autoload/eager_load roots, not just those ending in /controllers.
|
|
97
|
+
# Some apps add subdirectories of app/controllers (e.g. non_tab_controllers/)
|
|
98
|
+
# directly to autoload_paths so their contents load without a namespace prefix.
|
|
99
|
+
# Scanning all roots with the *_controller.rb glob has no false-positive risk.
|
|
100
|
+
all_paths = Rails.application.config.autoload_paths + Rails.application.config.eager_load_paths
|
|
101
|
+
all_paths.each { |p| dirs.add(p.to_s) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def extract_namespace(controller)
|
|
105
|
+
parts = controller.split('/')
|
|
106
|
+
parts.size > 1 ? parts[0..-2].join('/') : nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def excluded_path?(path)
|
|
110
|
+
@excluded_paths.any? { |prefix| path.start_with?(prefix) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def internal?(controller)
|
|
114
|
+
INTERNAL_PREFIXES.any? { |prefix| controller.start_with?(prefix) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def serialize_constraints(constraints)
|
|
118
|
+
constraints.except(:controller, :action)
|
|
119
|
+
.transform_values(&:to_s)
|
|
120
|
+
rescue StandardError
|
|
121
|
+
{}
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsVisualizer
|
|
4
|
+
module Schema
|
|
5
|
+
# Bulk-loads all column and index metadata with minimal DB round-trips.
|
|
6
|
+
# PostgreSQL: 2 parallel queries (one for columns, one for indexes).
|
|
7
|
+
# Other adapters: per-table fallback (still cached per table).
|
|
8
|
+
class Cache
|
|
9
|
+
Col = Struct.new(:name, :type, :null, :default, keyword_init: true)
|
|
10
|
+
Idx = Struct.new(:name, :unique, :columns, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
PG_TYPE_MAP = {
|
|
13
|
+
'int2' => 'integer', 'int4' => 'integer', 'int8' => 'integer',
|
|
14
|
+
'float4' => 'float', 'float8' => 'float',
|
|
15
|
+
'numeric' => 'decimal', 'money' => 'decimal',
|
|
16
|
+
'bool' => 'boolean',
|
|
17
|
+
'varchar' => 'string', 'bpchar' => 'string', 'text' => 'text',
|
|
18
|
+
'bytea' => 'binary',
|
|
19
|
+
'date' => 'date',
|
|
20
|
+
'timestamp' => 'datetime', 'timestamptz' => 'datetime',
|
|
21
|
+
'time' => 'time', 'timetz' => 'time',
|
|
22
|
+
'json' => 'json', 'jsonb' => 'jsonb',
|
|
23
|
+
'uuid' => 'uuid',
|
|
24
|
+
'inet' => 'inet', 'cidr' => 'cidr',
|
|
25
|
+
'macaddr' => 'string',
|
|
26
|
+
'xml' => 'xml',
|
|
27
|
+
'hstore' => 'hstore',
|
|
28
|
+
'ltree' => 'ltree',
|
|
29
|
+
'interval' => 'interval',
|
|
30
|
+
'_int4' => 'integer', '_int8' => 'integer',
|
|
31
|
+
'_float8' => 'float',
|
|
32
|
+
'_varchar' => 'string', '_text' => 'string'
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
def initialize
|
|
36
|
+
@columns = {}
|
|
37
|
+
@indexes = {}
|
|
38
|
+
@index_maps = {}
|
|
39
|
+
@primary_keys = {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def table_exists?(table_name)
|
|
43
|
+
tables.include?(table_name.to_s)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def columns_for(table_name)
|
|
47
|
+
@columns.fetch(table_name.to_s, [])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def indexes_for(table_name)
|
|
51
|
+
@indexes.fetch(table_name.to_s, [])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def index_map_for(table_name)
|
|
55
|
+
tn = table_name.to_s
|
|
56
|
+
@index_maps[tn] ||= build_index_map(tn)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def primary_key_for(table_name)
|
|
60
|
+
@primary_keys.fetch(table_name.to_s, 'id')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def preload_for(table_names)
|
|
64
|
+
needed = table_names.select { |t| table_exists?(t) }
|
|
65
|
+
return if needed.empty?
|
|
66
|
+
|
|
67
|
+
if postgresql?
|
|
68
|
+
parallel_bulk_load_pg(needed)
|
|
69
|
+
else
|
|
70
|
+
fallback_preload(needed)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def build_index_map(table_name)
|
|
77
|
+
indexes_for(table_name).each_with_object({}) do |index, hash|
|
|
78
|
+
next unless index.columns.is_a?(Array)
|
|
79
|
+
|
|
80
|
+
index.columns.each do |col|
|
|
81
|
+
(hash[col] ||= []) << { name: index.name, unique: index.unique, columns: index.columns }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def tables
|
|
87
|
+
@tables ||= Set.new(connection.tables)
|
|
88
|
+
rescue StandardError
|
|
89
|
+
Set.new
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def postgresql?
|
|
93
|
+
connection.adapter_name =~ /postg/i
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Run column and index bulk queries in parallel threads.
|
|
97
|
+
# SQL I/O releases the GIL so both queries hit the DB concurrently.
|
|
98
|
+
def parallel_bulk_load_pg(needed)
|
|
99
|
+
t1 = Thread.new do
|
|
100
|
+
bulk_load_columns_pg(needed)
|
|
101
|
+
ensure
|
|
102
|
+
ActiveRecord::Base.connection_pool.release_connection
|
|
103
|
+
end
|
|
104
|
+
t2 = Thread.new do
|
|
105
|
+
bulk_load_indexes_pg(needed)
|
|
106
|
+
ensure
|
|
107
|
+
ActiveRecord::Base.connection_pool.release_connection
|
|
108
|
+
end
|
|
109
|
+
t1.join
|
|
110
|
+
t2.join
|
|
111
|
+
rescue StandardError
|
|
112
|
+
fallback_preload(needed)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def bulk_load_columns_pg(needed)
|
|
116
|
+
needed_set = needed.to_set
|
|
117
|
+
rows = fetch_pg_columns(needed)
|
|
118
|
+
rows.each do |row|
|
|
119
|
+
tname = row['table_name']
|
|
120
|
+
next unless needed_set.include?(tname)
|
|
121
|
+
|
|
122
|
+
(@columns[tname] ||= []) << Col.new(
|
|
123
|
+
name: row['column_name'],
|
|
124
|
+
type: pg_type(row['udt_name']),
|
|
125
|
+
null: row['is_nullable'] == 'YES',
|
|
126
|
+
default: row['column_default']
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
rescue StandardError
|
|
130
|
+
fallback_preload_columns(needed - @columns.keys)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def fetch_pg_columns(needed)
|
|
134
|
+
quoted = needed.map { |t| connection.quote(t) }.join(', ')
|
|
135
|
+
connection.select_all(<<~SQL) # rubocop:disable Rails/SquishedSQLHeredocs
|
|
136
|
+
SELECT table_name, column_name, ordinal_position,
|
|
137
|
+
column_default, is_nullable, udt_name
|
|
138
|
+
FROM information_schema.columns
|
|
139
|
+
WHERE table_schema = ANY(current_schemas(false))
|
|
140
|
+
AND table_name IN (#{quoted})
|
|
141
|
+
ORDER BY table_name, ordinal_position
|
|
142
|
+
SQL
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def bulk_load_indexes_pg(needed)
|
|
146
|
+
needed_set = needed.to_set
|
|
147
|
+
rows = fetch_pg_indexes(needed)
|
|
148
|
+
rows.each { |row| process_index_row(row, needed_set) }
|
|
149
|
+
rescue StandardError
|
|
150
|
+
fallback_preload_indexes(needed - @indexes.keys)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def fetch_pg_indexes(needed)
|
|
154
|
+
quoted = needed.map { |t| connection.quote(t) }.join(', ')
|
|
155
|
+
connection.select_all(<<~SQL) # rubocop:disable Rails/SquishedSQLHeredocs
|
|
156
|
+
SELECT t.relname AS table_name,
|
|
157
|
+
i.relname AS index_name,
|
|
158
|
+
ix.indisunique AS is_unique,
|
|
159
|
+
ix.indisprimary AS is_primary,
|
|
160
|
+
array_agg(a.attname ORDER BY x.n) AS columns
|
|
161
|
+
FROM pg_index ix
|
|
162
|
+
JOIN pg_class t ON t.oid = ix.indrelid
|
|
163
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
164
|
+
JOIN pg_namespace ns ON ns.oid = t.relnamespace
|
|
165
|
+
CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n)
|
|
166
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
|
|
167
|
+
WHERE ns.nspname = ANY(current_schemas(false))
|
|
168
|
+
AND t.relname IN (#{quoted})
|
|
169
|
+
GROUP BY t.relname, i.relname, ix.indisunique, ix.indisprimary
|
|
170
|
+
SQL
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def process_index_row(row, needed_set)
|
|
174
|
+
tname = row['table_name']
|
|
175
|
+
return unless needed_set.include?(tname)
|
|
176
|
+
|
|
177
|
+
cols = parse_pg_array(row['columns'])
|
|
178
|
+
|
|
179
|
+
if row['is_primary']
|
|
180
|
+
@primary_keys[tname] = cols.first
|
|
181
|
+
else
|
|
182
|
+
(@indexes[tname] ||= []) << Idx.new(name: row['index_name'], unique: row['is_unique'], columns: cols)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def fallback_preload(table_names)
|
|
187
|
+
conn = connection
|
|
188
|
+
table_names.each do |t|
|
|
189
|
+
@columns[t] ||= conn.columns(t)
|
|
190
|
+
@indexes[t] ||= conn.indexes(t)
|
|
191
|
+
@primary_keys[t] ||= conn.primary_key(t) || 'id'
|
|
192
|
+
rescue StandardError
|
|
193
|
+
@columns[t] ||= []
|
|
194
|
+
@indexes[t] ||= []
|
|
195
|
+
@primary_keys[t] ||= 'id'
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def fallback_preload_columns(table_names)
|
|
200
|
+
conn = connection
|
|
201
|
+
table_names.each do |t|
|
|
202
|
+
@columns[t] ||= conn.columns(t)
|
|
203
|
+
rescue StandardError
|
|
204
|
+
@columns[t] ||= []
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def fallback_preload_indexes(table_names)
|
|
209
|
+
conn = connection
|
|
210
|
+
table_names.each do |t|
|
|
211
|
+
@indexes[t] ||= conn.indexes(t)
|
|
212
|
+
rescue StandardError
|
|
213
|
+
@indexes[t] ||= []
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def pg_type(udt_name)
|
|
218
|
+
PG_TYPE_MAP.fetch(udt_name, 'string')
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def parse_pg_array(value)
|
|
222
|
+
case value
|
|
223
|
+
when Array then value.map(&:to_s)
|
|
224
|
+
when String then value.delete('{}').split(',')
|
|
225
|
+
else []
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def connection
|
|
230
|
+
ActiveRecord::Base.connection
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsVisualizer
|
|
4
|
+
module Schema
|
|
5
|
+
class IndexInspector
|
|
6
|
+
def initialize(model)
|
|
7
|
+
@model = model
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Returns a hash of column_name => [index_info, ...] for the model's table.
|
|
11
|
+
def call
|
|
12
|
+
return {} unless safe_table_exists?
|
|
13
|
+
|
|
14
|
+
safe_indexes.each_with_object({}) do |index, hash|
|
|
15
|
+
next unless index.columns.is_a?(Array)
|
|
16
|
+
|
|
17
|
+
index.columns.each do |column|
|
|
18
|
+
hash[column] ||= []
|
|
19
|
+
hash[column] << {
|
|
20
|
+
name: index.name,
|
|
21
|
+
unique: index.unique,
|
|
22
|
+
columns: index.columns
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def safe_table_exists?
|
|
31
|
+
@model.connection.table_exists?(@model.table_name)
|
|
32
|
+
rescue StandardError
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def safe_indexes
|
|
37
|
+
@model.connection.indexes(@model.table_name)
|
|
38
|
+
rescue StandardError
|
|
39
|
+
[]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|