rails-schema 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,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Schema
5
+ class Configuration
6
+ attr_accessor :output_path, :exclude_models, :title, :theme, :expand_columns
7
+
8
+ def initialize
9
+ @output_path = "docs/schema.html"
10
+ @exclude_models = []
11
+ @title = "Database Schema"
12
+ @theme = :auto
13
+ @expand_columns = false
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Schema
5
+ module Extractor
6
+ class AssociationReader
7
+ def read(model)
8
+ model.reflect_on_all_associations.filter_map do |ref|
9
+ next if skip_association?(ref)
10
+
11
+ build_association_data(model, ref)
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def skip_association?(ref)
18
+ # Skip polymorphic belongs_to — no fixed target model
19
+ ref.macro == :belongs_to && ref.options[:polymorphic]
20
+ end
21
+
22
+ def build_association_data(model, ref)
23
+ {
24
+ from: model.name,
25
+ to: target_model_name(ref),
26
+ association_type: ref.macro.to_s,
27
+ label: ref.name.to_s,
28
+ foreign_key: ref.foreign_key.to_s,
29
+ through: ref.options[:through]&.to_s,
30
+ polymorphic: ref.options[:as] ? true : false
31
+ }
32
+ rescue StandardError
33
+ nil
34
+ end
35
+
36
+ def target_model_name(ref)
37
+ ref.klass.name
38
+ rescue StandardError
39
+ ref.class_name
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Schema
5
+ module Extractor
6
+ class ColumnReader
7
+ def initialize(schema_data: nil)
8
+ @schema_data = schema_data
9
+ end
10
+
11
+ def read(model)
12
+ if @schema_data&.key?(model.table_name)
13
+ @schema_data[model.table_name]
14
+ else
15
+ read_from_model(model)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def read_from_model(model)
22
+ model.columns.map do |col|
23
+ {
24
+ name: col.name,
25
+ type: col.type.to_s,
26
+ nullable: col.null,
27
+ primary: col.name == model.primary_key,
28
+ default: col.default
29
+ }
30
+ end
31
+ rescue StandardError
32
+ []
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Schema
5
+ module Extractor
6
+ class ModelScanner
7
+ def initialize(configuration: ::Rails::Schema.configuration, schema_data: nil)
8
+ @configuration = configuration
9
+ @schema_data = schema_data
10
+ end
11
+
12
+ def scan
13
+ eager_load_models!
14
+
15
+ all_descendants = ActiveRecord::Base.descendants
16
+ non_abstract = all_descendants.reject(&:abstract_class?)
17
+ named = non_abstract.reject { |m| m.name.nil? }
18
+ with_tables = named.select { |m| table_known?(m) }
19
+ included = with_tables.reject { |m| excluded?(m) }
20
+ log_empty_scan(all_descendants, non_abstract, named, with_tables) if included.empty?
21
+
22
+ included.sort_by(&:name)
23
+ end
24
+
25
+ private
26
+
27
+ def eager_load_models!
28
+ return unless defined?(::Rails.application) && ::Rails.application
29
+
30
+ if defined?(::Rails.autoloaders) && ::Rails.autoloaders.respond_to?(:main)
31
+ eager_load_via_zeitwerk!
32
+ else
33
+ eager_load_via_application!
34
+ end
35
+ end
36
+
37
+ def eager_load_via_zeitwerk!
38
+ loader = ::Rails.autoloaders.main
39
+ models_path = ::Rails.root&.join("app", "models")&.to_s
40
+
41
+ if models_path && File.directory?(models_path) && loader.respond_to?(:eager_load_dir)
42
+ loader.eager_load_dir(models_path)
43
+ else
44
+ loader.eager_load
45
+ end
46
+ rescue StandardError => e
47
+ warn "[rails-schema] Zeitwerk eager_load failed (#{e.class}: #{e.message}), " \
48
+ "trying Rails.application.eager_load!"
49
+ eager_load_via_application!
50
+ end
51
+
52
+ def eager_load_via_application!
53
+ ::Rails.application.eager_load!
54
+ rescue StandardError => e
55
+ warn "[rails-schema] eager_load! failed (#{e.class}: #{e.message}), " \
56
+ "falling back to per-file model loading"
57
+ eager_load_model_files!
58
+ end
59
+
60
+ def eager_load_model_files!
61
+ return unless defined?(::Rails.root) && ::Rails.root
62
+
63
+ models_path = ::Rails.root.join("app", "models")
64
+ return unless models_path.exist?
65
+
66
+ Dir.glob(models_path.join("**/*.rb")).each do |file|
67
+ require file
68
+ rescue StandardError => e
69
+ warn "[rails-schema] Could not load #{file}: #{e.class}: #{e.message}"
70
+ end
71
+ end
72
+
73
+ def table_known?(model)
74
+ if @schema_data
75
+ @schema_data.key?(model.table_name)
76
+ else
77
+ safe_table_exists?(model)
78
+ end
79
+ end
80
+
81
+ def safe_table_exists?(model)
82
+ model.table_exists?
83
+ rescue StandardError => e
84
+ warn "[rails-schema] Could not check table for #{model.name}: #{e.class}: #{e.message}"
85
+ false
86
+ end
87
+
88
+ def excluded?(model)
89
+ @configuration.exclude_models.any? do |pattern|
90
+ if pattern.end_with?("*")
91
+ model.name.start_with?(pattern.delete_suffix("*"))
92
+ else
93
+ model.name == pattern
94
+ end
95
+ end
96
+ end
97
+
98
+ def log_empty_scan(all_descendants, non_abstract, named, with_tables)
99
+ return if all_descendants.empty?
100
+
101
+ warn "[rails-schema] No models found! Filtering: " \
102
+ "#{all_descendants.size} descendants → #{non_abstract.size} concrete → " \
103
+ "#{named.size} named → #{with_tables.size} with tables"
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Schema
5
+ module Extractor
6
+ class SchemaFileParser
7
+ def initialize(schema_path = nil)
8
+ @schema_path = schema_path
9
+ end
10
+
11
+ def parse
12
+ path = resolve_path
13
+ return {} unless path && File.exist?(path)
14
+
15
+ parse_content(File.read(path))
16
+ end
17
+
18
+ def parse_content(content)
19
+ @tables = {}
20
+ @current_table = nil
21
+ @pk_type = nil
22
+ @has_pk = true
23
+
24
+ content.each_line { |line| process_line(line.strip) }
25
+
26
+ @tables
27
+ end
28
+
29
+ private
30
+
31
+ def resolve_path
32
+ return @schema_path if @schema_path
33
+
34
+ if defined?(::Rails.root) && ::Rails.root
35
+ ::Rails.root.join("db", "schema.rb").to_s
36
+ else
37
+ File.join(Dir.pwd, "db", "schema.rb")
38
+ end
39
+ end
40
+
41
+ def extract_pk_type(line)
42
+ if (match = line.match(/id:\s*:(\w+)/))
43
+ match[1]
44
+ else
45
+ "integer"
46
+ end
47
+ end
48
+
49
+ def parse_column(line)
50
+ return nil if line.start_with?("t.index")
51
+
52
+ match = line.match(/\At\.(\w+)\s+"(\w+)"(.*)/)
53
+ return nil unless match
54
+
55
+ type = match[1]
56
+ name = match[2]
57
+ options = match[3]
58
+
59
+ {
60
+ name: name,
61
+ type: type,
62
+ nullable: !options.match?(/null:\s*false/),
63
+ default: extract_default(options),
64
+ primary: false
65
+ }
66
+ end
67
+
68
+ def process_line(stripped)
69
+ if (match = stripped.match(/\Acreate_table\s+"(\w+)"/))
70
+ start_table(match, stripped)
71
+ elsif @current_table && stripped == "end"
72
+ close_table
73
+ elsif @current_table && (col = parse_column(stripped))
74
+ @tables[@current_table] << col
75
+ end
76
+ end
77
+
78
+ def start_table(match, stripped)
79
+ @current_table = match[1]
80
+ @tables[@current_table] = []
81
+ @has_pk = !stripped.match?(/id:\s*false/)
82
+ @pk_type = extract_pk_type(stripped)
83
+ end
84
+
85
+ def close_table
86
+ if @has_pk
87
+ pk_column = { name: "id", type: @pk_type, nullable: false, default: nil, primary: true }
88
+ @tables[@current_table].unshift(pk_column)
89
+ end
90
+ @current_table = nil
91
+ @pk_type = nil
92
+ @has_pk = true
93
+ end
94
+
95
+ def extract_default(options)
96
+ if (match = options.match(/default:\s*(?:"([^"]*)"|(\d+(?:\.\d+)?))/))
97
+ match[1] || match[2]
98
+ elsif options.match?(/default:\s*true/)
99
+ "true"
100
+ elsif options.match?(/default:\s*false/)
101
+ "false"
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Schema
5
+ class Railtie < ::Rails::Railtie
6
+ rake_tasks do
7
+ namespace :rails_schema do
8
+ desc "Generate an interactive HTML schema diagram"
9
+ task generate: :environment do
10
+ path = ::Rails::Schema.generate
11
+ puts "Schema diagram generated: #{path}"
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ module Rails
8
+ module Schema
9
+ module Renderer
10
+ class HtmlGenerator
11
+ ASSETS_DIR = File.expand_path("../../schema/assets", __dir__)
12
+
13
+ def initialize(graph_data:, configuration: ::Rails::Schema.configuration)
14
+ @graph_data = graph_data
15
+ @configuration = configuration
16
+ end
17
+
18
+ def render
19
+ template = File.read(File.join(ASSETS_DIR, "template.html.erb"))
20
+ erb = ERB.new(template)
21
+ erb.result(binding)
22
+ end
23
+
24
+ def render_to_file(path = nil)
25
+ path ||= @configuration.output_path
26
+ FileUtils.mkdir_p(File.dirname(path))
27
+ File.write(path, render)
28
+ path
29
+ end
30
+
31
+ private
32
+
33
+ def title
34
+ @configuration.title
35
+ end
36
+
37
+ def theme_class
38
+ case @configuration.theme
39
+ when :light then "light"
40
+ when :dark then "dark"
41
+ else ""
42
+ end
43
+ end
44
+
45
+ def css_content
46
+ File.read(File.join(ASSETS_DIR, "style.css"))
47
+ end
48
+
49
+ def d3_js_content
50
+ File.read(File.join(ASSETS_DIR, "vendor", "d3.min.js"))
51
+ end
52
+
53
+ def app_js_content
54
+ File.read(File.join(ASSETS_DIR, "app.js"))
55
+ end
56
+
57
+ def graph_json
58
+ JSON.generate(@graph_data).gsub("</", '<\/')
59
+ end
60
+
61
+ def config_json
62
+ config = {
63
+ expand_columns: @configuration.expand_columns,
64
+ theme: @configuration.theme.to_s
65
+ }
66
+ JSON.generate(config).gsub("</", '<\/')
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Schema
5
+ module Transformer
6
+ class Edge
7
+ attr_reader :from, :to, :association_type, :label, :foreign_key, :through, :polymorphic
8
+
9
+ def initialize(from:, to:, association_type:, label:, foreign_key: nil, through: nil, polymorphic: false)
10
+ @from = from
11
+ @to = to
12
+ @association_type = association_type
13
+ @label = label
14
+ @foreign_key = foreign_key
15
+ @through = through
16
+ @polymorphic = polymorphic
17
+ end
18
+
19
+ def to_h
20
+ {
21
+ from: @from,
22
+ to: @to,
23
+ association_type: @association_type,
24
+ label: @label,
25
+ foreign_key: @foreign_key,
26
+ through: @through,
27
+ polymorphic: @polymorphic
28
+ }
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Schema
5
+ module Transformer
6
+ class GraphBuilder
7
+ def initialize(column_reader: Extractor::ColumnReader.new, association_reader: Extractor::AssociationReader.new)
8
+ @column_reader = column_reader
9
+ @association_reader = association_reader
10
+ end
11
+
12
+ def build(models)
13
+ node_names = models.to_set(&:name)
14
+ nodes = models.map { |m| build_node(m) }
15
+ edges = models.flat_map { |m| build_edges(m, node_names) }
16
+
17
+ {
18
+ nodes: nodes.map(&:to_h),
19
+ edges: edges.map(&:to_h),
20
+ metadata: build_metadata(models)
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def build_node(model)
27
+ Node.new(
28
+ id: model.name,
29
+ table_name: model.table_name,
30
+ columns: @column_reader.read(model)
31
+ )
32
+ end
33
+
34
+ def build_edges(model, node_names)
35
+ @association_reader.read(model).filter_map do |assoc|
36
+ next unless node_names.include?(assoc[:to])
37
+
38
+ Edge.new(
39
+ from: assoc[:from],
40
+ to: assoc[:to],
41
+ association_type: assoc[:association_type],
42
+ label: assoc[:label],
43
+ foreign_key: assoc[:foreign_key],
44
+ through: assoc[:through],
45
+ polymorphic: assoc[:polymorphic]
46
+ )
47
+ end
48
+ end
49
+
50
+ def build_metadata(models)
51
+ {
52
+ generated_at: Time.now.utc.iso8601,
53
+ model_count: models.size,
54
+ rails_version: defined?(::Rails.version) ? ::Rails.version : nil
55
+ }
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Schema
5
+ module Transformer
6
+ class Node
7
+ attr_reader :id, :table_name, :columns
8
+
9
+ def initialize(id:, table_name:, columns: [])
10
+ @id = id
11
+ @table_name = table_name
12
+ @columns = columns
13
+ end
14
+
15
+ def to_h
16
+ {
17
+ id: @id,
18
+ table_name: @table_name,
19
+ columns: @columns
20
+ }
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Schema
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "schema/version"
4
+ require_relative "schema/configuration"
5
+ require_relative "schema/transformer/node"
6
+ require_relative "schema/transformer/edge"
7
+ require_relative "schema/extractor/schema_file_parser"
8
+ require_relative "schema/extractor/model_scanner"
9
+ require_relative "schema/extractor/column_reader"
10
+ require_relative "schema/extractor/association_reader"
11
+ require_relative "schema/transformer/graph_builder"
12
+ require_relative "schema/renderer/html_generator"
13
+
14
+ module Rails
15
+ module Schema
16
+ class Error < StandardError; end
17
+
18
+ class << self
19
+ def configuration
20
+ @configuration ||= Configuration.new
21
+ end
22
+
23
+ def configure
24
+ yield(configuration)
25
+ end
26
+
27
+ def reset_configuration!
28
+ @configuration = Configuration.new
29
+ end
30
+
31
+ def generate(output: nil)
32
+ schema_data = Extractor::SchemaFileParser.new.parse
33
+ models = Extractor::ModelScanner.new(schema_data: schema_data).scan
34
+ column_reader = Extractor::ColumnReader.new(schema_data: schema_data)
35
+ graph_data = Transformer::GraphBuilder.new(column_reader: column_reader).build(models)
36
+ generator = Renderer::HtmlGenerator.new(graph_data: graph_data)
37
+ generator.render_to_file(output)
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ require_relative "schema/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,6 @@
1
+ module Rails
2
+ module Schema
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-schema
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrei Kislichenko
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: railties
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '6.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '6.0'
40
+ description: Introspects a Rails app's models, associations, and columns, then generates
41
+ a single self-contained HTML file with an interactive entity-relationship diagram.
42
+ email:
43
+ - android.2net@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - LICENSE.txt
49
+ - PROJECT.md
50
+ - README.md
51
+ - Rakefile
52
+ - docs/index.html
53
+ - docs/screenshot.png
54
+ - lib/rails/schema.rb
55
+ - lib/rails/schema/assets/app.js
56
+ - lib/rails/schema/assets/style.css
57
+ - lib/rails/schema/assets/template.html.erb
58
+ - lib/rails/schema/assets/vendor/d3.min.js
59
+ - lib/rails/schema/configuration.rb
60
+ - lib/rails/schema/extractor/association_reader.rb
61
+ - lib/rails/schema/extractor/column_reader.rb
62
+ - lib/rails/schema/extractor/model_scanner.rb
63
+ - lib/rails/schema/extractor/schema_file_parser.rb
64
+ - lib/rails/schema/railtie.rb
65
+ - lib/rails/schema/renderer/html_generator.rb
66
+ - lib/rails/schema/transformer/edge.rb
67
+ - lib/rails/schema/transformer/graph_builder.rb
68
+ - lib/rails/schema/transformer/node.rb
69
+ - lib/rails/schema/version.rb
70
+ - sig/rails/schema.rbs
71
+ homepage: https://github.com/nicholaides/rails-schema
72
+ licenses:
73
+ - MIT
74
+ metadata:
75
+ homepage_uri: https://github.com/nicholaides/rails-schema
76
+ source_code_uri: https://github.com/nicholaides/rails-schema
77
+ changelog_uri: https://github.com/nicholaides/rails-schema/blob/main/CHANGELOG.md
78
+ rubygems_mfa_required: 'true'
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: 3.2.0
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 3.6.9
94
+ specification_version: 4
95
+ summary: Interactive HTML visualization of your Rails database schema
96
+ test_files: []