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,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsVisualizer
4
+ module Schema
5
+ class ModelInspector
6
+ include RailsVisualizer::PathHelper
7
+
8
+ SERIALIZABLE_TYPES = [String, Symbol, Integer, Float, TrueClass, FalseClass, NilClass, Array, Hash].freeze
9
+ CALLBACK_TYPES = %w[validate save create update destroy commit rollback find initialize touch].freeze
10
+
11
+ # file_cache and method_location_cache are shared across all ModelInspector
12
+ # instances in a single run to avoid redundant file reads and instance_method
13
+ # lookups for concern/parent methods included by many models.
14
+ def initialize(model, schema_cache = nil, file_cache = nil, method_location_cache = nil)
15
+ @model = model
16
+ @schema_cache = schema_cache
17
+ @file_cache = file_cache || {}
18
+ @method_location_cache = method_location_cache || {}
19
+ @index_map = if schema_cache
20
+ schema_cache.index_map_for(model.table_name)
21
+ else
22
+ IndexInspector.new(model).call
23
+ end
24
+ @method_cache = {}
25
+ end
26
+
27
+ def call
28
+ {
29
+ name: @model.name,
30
+ table_name: @model.table_name,
31
+ abstract: false,
32
+ file_path: source_path_for(@model),
33
+ columns: columns,
34
+ associations: associations,
35
+ validations: validations,
36
+ enums: enums,
37
+ scopes: scopes,
38
+ callbacks: callbacks
39
+ }
40
+ end
41
+
42
+ private
43
+
44
+ def columns
45
+ return [] unless table_exists?
46
+
47
+ raw = @schema_cache ? @schema_cache.columns_for(@model.table_name) : @model.columns
48
+ ignored = ignored_column_names
49
+ raw = raw.reject { |col| ignored.include?(col.name) } if ignored.any?
50
+ raw.map { |col| build_column(col) }
51
+ end
52
+
53
+ def build_column(col)
54
+ data = {
55
+ name: col.name,
56
+ type: col.type.to_s,
57
+ primary: col.name == model_primary_key,
58
+ null: col.null,
59
+ default: col.default
60
+ }
61
+ data[:index] = @index_map[col.name].first if @index_map[col.name]
62
+ enum_vals = cached_enums[col.name]
63
+ data[:enum] = enum_vals.keys if enum_vals
64
+ data
65
+ end
66
+
67
+ def associations
68
+ @model.reflect_on_all_associations.map { |r| build_association(r) }
69
+ end
70
+
71
+ def build_association(reflection)
72
+ assoc = {
73
+ macro: reflection.macro.to_s,
74
+ name: reflection.name.to_s,
75
+ class_name: resolve_class_name(reflection),
76
+ foreign_key: resolve_foreign_key(reflection)
77
+ }
78
+ apply_assoc_options(assoc, reflection.options)
79
+ assoc
80
+ end
81
+
82
+ def apply_assoc_options(assoc, opts)
83
+ assoc[:through] = opts[:through].to_s if opts[:through]
84
+ assoc[:as] = opts[:as].to_s if opts[:as]
85
+ assoc[:polymorphic] = true if opts[:polymorphic]
86
+ assoc[:dependent] = opts[:dependent].to_s if opts[:dependent]
87
+ end
88
+
89
+ def validations
90
+ attribute_validators + method_validators
91
+ rescue StandardError
92
+ []
93
+ end
94
+
95
+ def attribute_validators
96
+ @model.validators.map do |v|
97
+ {
98
+ kind: v.kind.to_s,
99
+ attributes: v.attributes.map(&:to_s),
100
+ options: serializable_options(v.options),
101
+ validator_class: v.class.name
102
+ }
103
+ end
104
+ rescue StandardError
105
+ []
106
+ end
107
+
108
+ def method_validators
109
+ @model._validate_callbacks.filter_map do |cb|
110
+ next unless cb.filter.is_a?(Symbol)
111
+ next unless app_method_info(cb.filter)
112
+
113
+ { kind: cb.filter.to_s, attributes: [], options: {}, validator_class: nil }
114
+ end
115
+ rescue StandardError
116
+ []
117
+ end
118
+
119
+ # Two-level cache: per-model @method_cache avoids repeated instance_method
120
+ # lookups within one model, and @method_location_cache (shared across ALL
121
+ # models) caches the app_root check by [owner_id, method_name] — so a
122
+ # concern method included by 500 models does one source_location check globally.
123
+ def app_method_info(name)
124
+ @method_cache.fetch(name) do
125
+ unbound = @model.instance_method(name)
126
+ owner = unbound.owner
127
+ loc_key = [owner.object_id, name]
128
+
129
+ app_defined = @method_location_cache.fetch(loc_key) do
130
+ location = unbound.source_location
131
+ @method_location_cache[loc_key] = location&.first&.start_with?(app_root)
132
+ end
133
+
134
+ @method_cache[name] = app_defined ? owner : nil
135
+ end
136
+ rescue StandardError
137
+ @method_cache[name] = nil
138
+ end
139
+
140
+ def serializable_options(options)
141
+ options.each_with_object({}) do |(key, value), hash|
142
+ next if %i[if unless on].include?(key)
143
+ next unless SERIALIZABLE_TYPES.any? { |t| value.is_a?(t) }
144
+
145
+ hash[key] = value.is_a?(Symbol) ? value.to_s : value
146
+ end
147
+ end
148
+
149
+ def cached_enums
150
+ @cached_enums ||= @model.defined_enums
151
+ rescue StandardError
152
+ @cached_enums ||= {}
153
+ end
154
+
155
+ def enums
156
+ cached_enums.transform_values(&:keys)
157
+ end
158
+
159
+ def scopes
160
+ file_path = source_path_for(@model)
161
+ return [] unless file_path
162
+
163
+ content = read_file(file_path)
164
+ return [] unless content
165
+
166
+ content.scan(/^\s*scope\s+:(\w+)/).flatten
167
+ rescue StandardError
168
+ []
169
+ end
170
+
171
+ def read_file(relative_path)
172
+ @file_cache.fetch(relative_path) do
173
+ absolute = Rails.root.join(relative_path)
174
+ @file_cache[relative_path] = absolute.exist? ? File.read(absolute) : nil
175
+ end
176
+ rescue StandardError
177
+ @file_cache[relative_path] = nil
178
+ end
179
+
180
+ def callbacks
181
+ CALLBACK_TYPES.flat_map do |type|
182
+ chain = @model.send(:"_#{type}_callbacks")
183
+ chain.filter_map do |cb|
184
+ next unless cb.filter.is_a?(Symbol)
185
+
186
+ owner = app_method_info(cb.filter)
187
+ next unless owner
188
+
189
+ {
190
+ kind: "#{cb.kind}_#{type}",
191
+ name: cb.filter.to_s,
192
+ defined_in: owner == @model ? nil : owner.name,
193
+ conditional: cb.instance_variable_get(:@if).present? ||
194
+ cb.instance_variable_get(:@unless).present?
195
+ }
196
+ end
197
+ rescue StandardError
198
+ []
199
+ end
200
+ rescue StandardError
201
+ []
202
+ end
203
+
204
+ def model_primary_key
205
+ @model_primary_key ||= if @schema_cache
206
+ @schema_cache.primary_key_for(@model.table_name)
207
+ else
208
+ @model.primary_key
209
+ end
210
+ rescue StandardError
211
+ 'id'
212
+ end
213
+
214
+ def table_exists?
215
+ if @schema_cache
216
+ @schema_cache.table_exists?(@model.table_name)
217
+ else
218
+ @model.connection.table_exists?(@model.table_name)
219
+ end
220
+ rescue StandardError
221
+ false
222
+ end
223
+
224
+ def ignored_column_names
225
+ Set.new(@model.ignored_columns.map(&:to_s))
226
+ rescue StandardError
227
+ Set.new
228
+ end
229
+
230
+ def resolve_class_name(reflection)
231
+ return nil if reflection.options[:polymorphic]
232
+
233
+ reflection.class_name
234
+ rescue NameError
235
+ nil
236
+ end
237
+
238
+ def resolve_foreign_key(reflection)
239
+ reflection.foreign_key.to_s
240
+ rescue StandardError
241
+ nil
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+ require 'time'
6
+
7
+ module RailsVisualizer
8
+ LOGO_CANDIDATES = [
9
+ ['public/favicon.svg', 'image/svg+xml'],
10
+ ['public/favicon.png', 'image/png'],
11
+ ['public/favicon.ico', 'image/x-icon'],
12
+ ['public/apple-touch-icon.png', 'image/png']
13
+ ].freeze
14
+
15
+ class Serializer
16
+ def initialize(app_data, configuration = RailsVisualizer.configuration)
17
+ @app_data = app_data
18
+ @configuration = configuration
19
+ @key_cache = {}
20
+ end
21
+
22
+ def call
23
+ JSON.generate(deep_camelize(payload))
24
+ end
25
+
26
+ private
27
+
28
+ def payload
29
+ {
30
+ generated_at: Time.now.utc.iso8601,
31
+ rails_version: rails_version,
32
+ ruby_version: RUBY_VERSION,
33
+ db_version: db_version,
34
+ app_name: @app_data.fetch(:app_name, nil),
35
+ app_title: app_title,
36
+ app_logo: app_logo_data_uri,
37
+ app_root: app_root,
38
+ models: @app_data.fetch(:models, []),
39
+ routes: @app_data.fetch(:routes, []),
40
+ jobs: @app_data.fetch(:jobs, {}),
41
+ controllers: @app_data.fetch(:controllers, []),
42
+ migrations: @app_data.fetch(:migrations, {}),
43
+ mailers: @app_data.fetch(:mailers, []),
44
+ gems: @app_data.fetch(:gems, [])
45
+ }
46
+ end
47
+
48
+ def rails_version
49
+ Rails::VERSION::STRING if defined?(Rails::VERSION)
50
+ end
51
+
52
+ def app_title
53
+ root = app_root
54
+ layout = File.join(root, 'app/views/layouts/application.html.erb')
55
+ return nil unless File.file?(layout)
56
+
57
+ content = File.read(layout)
58
+ # Only extract plain-text titles — skip any that contain ERB expressions
59
+ match = content.match(%r{<title>([^<%>]+)</title>})
60
+ title = match&.[](1)&.strip
61
+ title.presence
62
+ rescue StandardError
63
+ nil
64
+ end
65
+
66
+ def db_version
67
+ return nil unless defined?(ActiveRecord::Base)
68
+
69
+ conn = ActiveRecord::Base.connection
70
+ version = case conn.adapter_name.downcase
71
+ when /postgresql|postgis/
72
+ conn.select_value("SELECT current_setting('server_version')")
73
+ when /mysql/
74
+ conn.select_value('SELECT VERSION()')
75
+ when /sqlite/
76
+ conn.select_value('SELECT sqlite_version()')
77
+ end
78
+ version&.to_s&.strip
79
+ rescue StandardError
80
+ nil
81
+ end
82
+
83
+ def app_root
84
+ defined?(Rails) && Rails.respond_to?(:root) ? Rails.root.to_s : Dir.pwd
85
+ end
86
+
87
+ def app_logo_data_uri
88
+ root = app_root
89
+ RailsVisualizer::LOGO_CANDIDATES.each do |relative_path, mime|
90
+ full_path = File.join(root, relative_path)
91
+ next unless File.file?(full_path)
92
+
93
+ encoded = Base64.strict_encode64(File.binread(full_path))
94
+ return "data:#{mime};base64,#{encoded}"
95
+ rescue StandardError
96
+ next
97
+ end
98
+ nil
99
+ end
100
+
101
+ # Recursively converts all Hash keys from snake_case to camelCase.
102
+ # Key conversions are memoised — the same ~30 key names appear millions
103
+ # of times across thousands of models/controllers/routes.
104
+ def deep_camelize(obj)
105
+ case obj
106
+ when Hash
107
+ obj.each_with_object({}) do |(k, v), h|
108
+ h[camelize(k.to_s)] = deep_camelize(v)
109
+ end
110
+ when Array
111
+ obj.map { |v| deep_camelize(v) }
112
+ else
113
+ obj
114
+ end
115
+ end
116
+
117
+ def camelize(str)
118
+ @key_cache[str] ||= begin
119
+ parts = str.split('_')
120
+ parts.first + parts[1..].map(&:capitalize).join
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsVisualizer
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rails_visualizer/version'
4
+ require_relative 'rails_visualizer/configuration'
5
+ require_relative 'rails_visualizer/path_helper'
6
+ require_relative 'rails_visualizer/schema/cache'
7
+ require_relative 'rails_visualizer/schema/index_inspector'
8
+ require_relative 'rails_visualizer/schema/model_inspector'
9
+ require_relative 'rails_visualizer/introspector'
10
+ require_relative 'rails_visualizer/routes_inspector'
11
+ require_relative 'rails_visualizer/jobs_inspector'
12
+ require_relative 'rails_visualizer/controllers_inspector'
13
+ require_relative 'rails_visualizer/migrations_inspector'
14
+ require_relative 'rails_visualizer/mailers_inspector'
15
+ require_relative 'rails_visualizer/gems_inspector'
16
+ require_relative 'rails_visualizer/serializer'
17
+ require_relative 'rails_visualizer/renderer'
18
+
19
+ module RailsVisualizer
20
+ end
21
+
22
+ require_relative 'rails_visualizer/railtie' if defined?(Rails::Railtie)
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ desc 'Introspect the Rails app and open the interactive RailsVisualizer dashboard in your browser'
4
+ task rails_visualizer: :environment do # rubocop:disable Metrics/BlockLength
5
+ require 'rails_visualizer'
6
+
7
+ puts 'RailsVisualizer: Introspecting app...'
8
+ app_data = RailsVisualizer::Introspector.new.call
9
+
10
+ # Build a set of "controller#action" pairs from non-internal routes only
11
+ # (mirrors the UI's `routes.filter(r => !r.internal)` logic).
12
+ routed_pairs = app_data[:routes].each_with_object(Set.new) do |r, set|
13
+ set.add("#{r[:controller]}##{r[:action]}") unless r[:internal]
14
+ end
15
+
16
+ # Count public routable actions with no matching route, excluding actions that
17
+ # are registered as callbacks — they run as filters, not dispatched actions,
18
+ # so having no route for them is expected (mirrors deadCode.ts logic).
19
+ no_route_count = app_data[:controllers].sum do |ctrl|
20
+ callback_names = [
21
+ *(ctrl[:before_actions] || []),
22
+ *(ctrl[:after_actions] || []),
23
+ *(ctrl[:around_actions] || [])
24
+ ].to_set { |cb| cb[:name] }
25
+
26
+ (ctrl[:actions] || []).count do |action|
27
+ callback_names.exclude?(action[:name]) &&
28
+ routed_pairs.exclude?("#{ctrl[:controller_path]}##{action[:name]}")
29
+ end
30
+ end
31
+
32
+ pl = ->(n, singular, plural = "#{singular}s") { "#{n} #{n == 1 ? singular : plural}" }
33
+
34
+ pending_count = app_data.dig(:migrations, :pending_count) || 0
35
+ migration_icon = pending_count.positive? ? '⚠' : '✓'
36
+
37
+ puts " ✓ #{pl.call(app_data[:models].size, 'model')}"
38
+ puts " ✓ #{pl.call(app_data[:routes].size, 'route')}"
39
+ puts " ✓ #{pl.call(app_data.dig(:jobs, :active_jobs)&.size || 0, 'job')}"
40
+ controller_icon = no_route_count.positive? ? '⚠' : '✓'
41
+ no_route_suffix = no_route_count.positive? ? " (#{pl.call(no_route_count, 'action')} without route)" : ''
42
+ puts " #{controller_icon} #{pl.call(app_data[:controllers].size, 'controller')}#{no_route_suffix}"
43
+ pending_suffix = pending_count.positive? ? " (#{pl.call(pending_count, 'pending migration')})" : ''
44
+ puts " #{migration_icon} #{pl.call(app_data.dig(:migrations, :total_count) || 0, 'migration')}#{pending_suffix}"
45
+ puts " ✓ #{pl.call(app_data[:mailers].size, 'mailer')}"
46
+
47
+ puts 'RailsVisualizer: Rendering...'
48
+ json = RailsVisualizer::Serializer.new(app_data).call
49
+
50
+ output_dir = Rails.root.join(RailsVisualizer.configuration.output_path)
51
+ FileUtils.mkdir_p(output_dir)
52
+
53
+ output_file = output_dir.join(RailsVisualizer.configuration.filename)
54
+ File.write(output_file, RailsVisualizer::Renderer.new(json).call)
55
+
56
+ puts "RailsVisualizer: Done → #{output_file}"
57
+
58
+ system('open', output_file.to_s) if RailsVisualizer.configuration.open_browser
59
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_visualizer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Raj Kamal Lashkari
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-18 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |-
14
+ rails_visualizer introspects a Rails application and generates a self-contained
15
+ interactive HTML dashboard with no server, no config, and no Node.js required for
16
+ end users. Run one Rake task to explore models, associations, routes, controllers,
17
+ jobs, mailers, migrations, and gems through a polished 8-tab UI with search,
18
+ filters, an ER diagram canvas, and built-in health checks.
19
+ email:
20
+ - rajkamallashkari@gmail.com
21
+ executables: []
22
+ extensions: []
23
+ extra_rdoc_files: []
24
+ files:
25
+ - CHANGELOG.md
26
+ - LICENSE.txt
27
+ - README.md
28
+ - lib/rails_visualizer.rb
29
+ - lib/rails_visualizer/assets/dist/index.html
30
+ - lib/rails_visualizer/configuration.rb
31
+ - lib/rails_visualizer/controllers_inspector.rb
32
+ - lib/rails_visualizer/gems_inspector.rb
33
+ - lib/rails_visualizer/introspector.rb
34
+ - lib/rails_visualizer/jobs_inspector.rb
35
+ - lib/rails_visualizer/mailers_inspector.rb
36
+ - lib/rails_visualizer/migrations_inspector.rb
37
+ - lib/rails_visualizer/path_helper.rb
38
+ - lib/rails_visualizer/railtie.rb
39
+ - lib/rails_visualizer/renderer.rb
40
+ - lib/rails_visualizer/routes_inspector.rb
41
+ - lib/rails_visualizer/schema/cache.rb
42
+ - lib/rails_visualizer/schema/index_inspector.rb
43
+ - lib/rails_visualizer/schema/model_inspector.rb
44
+ - lib/rails_visualizer/serializer.rb
45
+ - lib/rails_visualizer/version.rb
46
+ - lib/tasks/rails_visualizer.rake
47
+ homepage: https://github.com/rajkamallashkari/rails_visualizer
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ changelog_uri: https://github.com/rajkamallashkari/rails_visualizer/blob/main/CHANGELOG.md
52
+ source_code_uri: https://github.com/rajkamallashkari/rails_visualizer
53
+ bug_tracker_uri: https://github.com/rajkamallashkari/rails_visualizer/issues
54
+ documentation_uri: https://github.com/rajkamallashkari/rails_visualizer#readme
55
+ rubygems_mfa_required: 'true'
56
+ post_install_message:
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '3.0'
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.5.22
72
+ signing_key:
73
+ specification_version: 4
74
+ summary: Interactive Rails app visualizer — models, routes, controllers, and more
75
+ in one dashboard.
76
+ test_files: []