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.
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsVisualizer
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ load File.join(__dir__, '..', 'tasks', 'rails_visualizer.rake')
7
+ end
8
+ end
9
+ 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