rails-schema 0.1.4 → 0.1.6

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.
@@ -99,7 +99,7 @@ body {
99
99
  #toolbar {
100
100
  position: fixed;
101
101
  top: 0;
102
- left: var(--sidebar-width);
102
+ left: 0;
103
103
  right: 0;
104
104
  height: var(--toolbar-height);
105
105
  background: var(--bg-secondary);
@@ -108,7 +108,7 @@ body {
108
108
  align-items: center;
109
109
  padding: 0 16px;
110
110
  gap: 8px;
111
- z-index: 100;
111
+ z-index: 200;
112
112
  }
113
113
 
114
114
  #toolbar button {
@@ -135,6 +135,13 @@ body {
135
135
  text-align: center;
136
136
  }
137
137
 
138
+ .toolbar-divider {
139
+ width: 1px;
140
+ height: 24px;
141
+ background: var(--border-color);
142
+ margin: 0 4px;
143
+ }
144
+
138
145
  #toolbar .shortcuts-hint {
139
146
  font-size: 11px;
140
147
  color: var(--text-muted);
@@ -148,7 +155,8 @@ body {
148
155
  border-right: 1px solid var(--border-color);
149
156
  display: flex;
150
157
  flex-direction: column;
151
- height: 100vh;
158
+ margin-top: var(--toolbar-height);
159
+ height: calc(100vh - var(--toolbar-height));
152
160
  z-index: 101;
153
161
  }
154
162
 
@@ -163,15 +171,39 @@ body {
163
171
  margin-bottom: 12px;
164
172
  }
165
173
 
174
+ #search-wrapper {
175
+ position: relative;
176
+ }
177
+
166
178
  #search-input {
167
179
  width: 100%;
168
- padding: 8px 12px;
180
+ padding: 8px 28px 8px 12px;
169
181
  border: 1px solid var(--border-color);
170
182
  border-radius: 6px;
171
183
  background: var(--bg-primary);
172
184
  color: var(--text-primary);
173
185
  font-size: 13px;
174
186
  outline: none;
187
+ box-sizing: border-box;
188
+ }
189
+
190
+ #search-clear {
191
+ position: absolute;
192
+ right: 4px;
193
+ top: 50%;
194
+ transform: translateY(-50%);
195
+ background: none;
196
+ border: none;
197
+ color: var(--text-secondary);
198
+ font-size: 16px;
199
+ cursor: pointer;
200
+ padding: 2px 6px;
201
+ line-height: 1;
202
+ display: none;
203
+ }
204
+
205
+ #search-clear:hover {
206
+ color: var(--text-primary);
175
207
  }
176
208
 
177
209
  #search-input:focus {
@@ -200,6 +232,10 @@ body {
200
232
  background: var(--bg-tertiary);
201
233
  }
202
234
 
235
+ #collapse-groups-btn {
236
+ margin-left: auto;
237
+ }
238
+
203
239
  #model-list {
204
240
  flex: 1;
205
241
  overflow-y: auto;
@@ -346,6 +382,14 @@ body {
346
382
  pointer-events: none;
347
383
  }
348
384
 
385
+ .edge-label.belongs_to { fill: var(--edge-belongs-to); }
386
+ .edge-label.has_many { fill: var(--edge-has-many); }
387
+ .edge-label.has_one { fill: var(--edge-has-one); }
388
+ .edge-label.has_and_belongs_to_many { fill: var(--edge-habtm); }
389
+ .edge-label.embeds_many { fill: var(--edge-embeds-many); }
390
+ .edge-label.embeds_one { fill: var(--edge-embeds-one); }
391
+ .edge-label.embedded_in { fill: var(--edge-embedded-in); }
392
+
349
393
  .edge-label-bg {
350
394
  fill: var(--bg-primary);
351
395
  opacity: 0.85;
@@ -430,6 +474,10 @@ body {
430
474
  .column-list .col-name {
431
475
  font-family: "SF Mono", "Fira Code", monospace;
432
476
  font-weight: 500;
477
+ overflow: hidden;
478
+ text-overflow: ellipsis;
479
+ white-space: nowrap;
480
+ min-width: 0;
433
481
  }
434
482
 
435
483
  .column-list .col-name.pk {
@@ -440,6 +488,8 @@ body {
440
488
  color: var(--text-muted);
441
489
  font-family: "SF Mono", "Fira Code", monospace;
442
490
  font-size: 11px;
491
+ flex-shrink: 0;
492
+ margin-left: 8px;
443
493
  }
444
494
 
445
495
  .assoc-list {
@@ -499,6 +549,15 @@ body {
499
549
  gap: 4px;
500
550
  }
501
551
 
552
+ .legend-toggle {
553
+ cursor: pointer;
554
+ }
555
+
556
+ .legend-toggle input[type="checkbox"] {
557
+ margin: 0;
558
+ cursor: pointer;
559
+ }
560
+
502
561
  .legend-line {
503
562
  width: 20px;
504
563
  height: 2px;
@@ -545,3 +604,59 @@ body {
545
604
  );
546
605
  background-color: transparent;
547
606
  }
607
+
608
+ /* Sidebar group headers */
609
+ .group-header {
610
+ display: flex;
611
+ align-items: center;
612
+ padding: 6px 16px;
613
+ font-size: 12px;
614
+ font-weight: 700;
615
+ border-bottom: 1px solid var(--border-color);
616
+ color: var(--text-secondary);
617
+ text-transform: uppercase;
618
+ letter-spacing: 0.5px;
619
+ cursor: pointer;
620
+ user-select: none;
621
+ gap: 6px;
622
+ }
623
+
624
+ .group-header:hover {
625
+ background: var(--bg-tertiary);
626
+ }
627
+
628
+ .group-header.active {
629
+ background: var(--accent-light);
630
+ }
631
+
632
+ .group-count {
633
+ font-size: 11px;
634
+ color: var(--text-secondary);
635
+ font-weight: normal;
636
+ margin-left: auto;
637
+ flex-shrink: 0;
638
+ }
639
+
640
+ .group-toggle {
641
+ font-size: 10px;
642
+ width: 12px;
643
+ flex-shrink: 0;
644
+ }
645
+
646
+ .group-color-dot {
647
+ width: 10px;
648
+ height: 10px;
649
+ border-radius: 50%;
650
+ display: inline-block;
651
+ flex-shrink: 0;
652
+ }
653
+
654
+ .group-header-label {
655
+ overflow: hidden;
656
+ text-overflow: ellipsis;
657
+ white-space: nowrap;
658
+ }
659
+
660
+ .group-models {
661
+ border-bottom: 1px solid var(--border-color);
662
+ }
@@ -11,11 +11,15 @@
11
11
  <div id="sidebar">
12
12
  <div id="sidebar-header">
13
13
  <h1><%= title %></h1>
14
- <input type="text" id="search-input" placeholder="Search models... ( / )" autocomplete="off">
14
+ <div id="search-wrapper">
15
+ <input type="text" id="search-input" placeholder="Search models... ( / )" autocomplete="off">
16
+ <button id="search-clear" type="button" aria-label="Clear search">&times;</button>
17
+ </div>
15
18
  </div>
16
19
  <div id="sidebar-actions">
17
20
  <button id="select-all-btn">Select All</button>
18
21
  <button id="deselect-all-btn">Deselect All</button>
22
+ <button id="collapse-groups-btn" style="display:none" title="Collapse all groups">&#9660;</button>
19
23
  </div>
20
24
  <div id="model-list"></div>
21
25
  </div>
@@ -28,7 +32,7 @@
28
32
  <span class="legend-item"><span class="legend-line habtm"></span> habtm (M:M)</span>
29
33
  <span class="legend-item" data-mode="mongoid"><span class="legend-line embeds_many"></span> embeds_many</span>
30
34
  <span class="legend-item" data-mode="mongoid"><span class="legend-line embeds_one"></span> embeds_one</span>
31
- <span class="legend-item" data-mode="active_record"><span class="legend-line through"></span> :through</span>
35
+ <label class="legend-item legend-toggle" data-mode="active_record"><input type="checkbox" id="toggle-through" checked><span class="legend-line through"></span> :through</label>
32
36
  <span class="legend-item"><span class="legend-line polymorphic"></span> polymorphic</span>
33
37
  </div>
34
38
  <div class="spacer"></div>
@@ -37,6 +41,8 @@
37
41
  <button id="zoom-out-btn" title="Zoom out (-)">−</button>
38
42
  <button id="fit-btn" title="Fit to screen (F)">Fit</button>
39
43
  <button id="theme-btn" title="Toggle theme">Theme</button>
44
+ <span class="toolbar-divider"></span>
45
+ <button id="export-mermaid-btn" title="Export as Mermaid (Markdown)">Export Mermaid</button>
40
46
  <span class="shortcuts-hint">/ search · Esc deselect · F fit</span>
41
47
  </div>
42
48
 
@@ -3,15 +3,31 @@
3
3
  module Rails
4
4
  module Schema
5
5
  class Configuration
6
- attr_accessor :output_path, :exclude_models, :title, :theme, :expand_columns, :schema_format
6
+ attr_accessor :output_path, :exclude_models, :exclude_model_if, :title, :theme, :expand_columns, :schema_format,
7
+ :model_schema_group, :collapse_groups, :show_through_edges
7
8
 
8
9
  def initialize
9
10
  @output_path = "docs/schema.html"
10
11
  @exclude_models = []
12
+ @exclude_model_if = nil
11
13
  @title = "Database Schema"
12
14
  @theme = :auto
13
15
  @expand_columns = false
14
16
  @schema_format = :auto
17
+ @model_schema_group = nil
18
+ @collapse_groups = true
19
+ @show_through_edges = true
20
+ end
21
+
22
+ def resolved_group_proc
23
+ case @model_schema_group
24
+ when nil then nil
25
+ when :namespaces
26
+ ->(model) { model.name.split("::")[0..-2] }
27
+ when Proc then @model_schema_group
28
+ else
29
+ raise ArgumentError, "model_schema_group must be nil, :namespaces, or a Proc"
30
+ end
15
31
  end
16
32
  end
17
33
  end
@@ -36,13 +36,9 @@ module Rails
36
36
 
37
37
  def eager_load_via_zeitwerk!
38
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
39
+ dirs = model_directories
40
+ dirs.each { |dir| loader.eager_load_dir(dir) } if dirs.any? && loader.respond_to?(:eager_load_dir)
41
+ loader.eager_load if no_concrete_descendants?
46
42
  rescue StandardError, LoadError => e
47
43
  warn "[rails-schema] Zeitwerk eager_load failed (#{e.class}: #{e.message}), " \
48
44
  "trying Rails.application.eager_load!"
@@ -60,22 +56,29 @@ module Rails
60
56
  def eager_load_model_files!
61
57
  return unless defined?(::Rails.root) && ::Rails.root
62
58
 
63
- models_path = ::Rails.root.join("app", "models")
64
- return unless models_path.exist?
59
+ dirs = model_directories
60
+ return if dirs.empty?
65
61
 
66
- Dir.glob(models_path.join("**/*.rb")).sort.each do |file|
62
+ dirs.flat_map { |d| Dir.glob(File.join(d, "**/*.rb")) }.sort.each do |file|
67
63
  require file
68
64
  rescue StandardError, LoadError => e
69
65
  warn "[rails-schema] Could not load #{file}: #{e.class}: #{e.message}"
70
66
  end
71
67
  end
72
68
 
69
+ def model_directories
70
+ dirs = PackwerkDiscovery.new.model_paths
71
+ default_path = ::Rails.root&.join("app", "models")&.to_s
72
+ dirs << default_path if default_path && !dirs.include?(default_path)
73
+ dirs.select { |d| d && File.directory?(d) }.uniq
74
+ end
75
+
76
+ def no_concrete_descendants?
77
+ ActiveRecord::Base.descendants.none? { |m| !m.abstract_class? && m.name }
78
+ end
79
+
73
80
  def table_known?(model)
74
- if @schema_data
75
- @schema_data.key?(model.table_name)
76
- else
77
- safe_table_exists?(model)
78
- end
81
+ @schema_data ? @schema_data.key?(model.table_name) : safe_table_exists?(model)
79
82
  end
80
83
 
81
84
  def safe_table_exists?(model)
@@ -86,12 +89,10 @@ module Rails
86
89
  end
87
90
 
88
91
  def excluded?(model)
92
+ return true if @configuration.exclude_model_if&.call(model)
93
+
89
94
  @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
+ pattern.end_with?("*") ? model.name.start_with?(pattern.delete_suffix("*")) : model.name == pattern
95
96
  end
96
97
  end
97
98
 
@@ -120,6 +120,8 @@ module Rails
120
120
  end
121
121
 
122
122
  def excluded?(model)
123
+ return true if @configuration.exclude_model_if&.call(model)
124
+
123
125
  @configuration.exclude_models.any? do |pattern|
124
126
  if pattern.end_with?("*")
125
127
  model.name.start_with?(pattern.delete_suffix("*"))
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Schema
5
+ module Extractor
6
+ class PackwerkDiscovery
7
+ def model_paths
8
+ return [] unless defined?(::Rails.root) && ::Rails.root
9
+
10
+ find_package_directories.flat_map do |dir|
11
+ %w[app/models app/public].map { |sub| File.join(dir, sub) }
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def find_package_directories
18
+ patterns = package_paths
19
+ return [] if patterns.empty?
20
+
21
+ patterns.flat_map do |pattern|
22
+ Dir.glob(::Rails.root.join(pattern).to_s).select do |path|
23
+ File.directory?(path) && File.exist?(File.join(path, "package.yml"))
24
+ end
25
+ end
26
+ end
27
+
28
+ def package_paths
29
+ packwerk_yml = ::Rails.root.join("packwerk.yml")
30
+ return [] unless File.exist?(packwerk_yml)
31
+
32
+ require "yaml"
33
+ config = YAML.safe_load(File.read(packwerk_yml)) || {}
34
+ config["package_paths"] || ["**/"]
35
+ rescue StandardError
36
+ []
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -61,7 +61,10 @@ module Rails
61
61
  def config_json
62
62
  config = {
63
63
  expand_columns: @configuration.expand_columns,
64
- theme: @configuration.theme.to_s
64
+ theme: @configuration.theme.to_s,
65
+ grouping_enabled: !@configuration.model_schema_group.nil?,
66
+ collapse_groups: @configuration.collapse_groups,
67
+ show_through_edges: @configuration.show_through_edges
65
68
  }
66
69
  JSON.generate(config).gsub("</", '<\/')
67
70
  end
@@ -5,6 +5,7 @@ module Rails
5
5
  module Transformer
6
6
  class Edge
7
7
  attr_reader :from, :to, :association_type, :label, :foreign_key, :through, :polymorphic
8
+ attr_accessor :reverse_label, :reverse_association_type
8
9
 
9
10
  def initialize(from:, to:, association_type:, label:, foreign_key: nil, through: nil, polymorphic: false)
10
11
  @from = from
@@ -14,6 +15,8 @@ module Rails
14
15
  @foreign_key = foreign_key
15
16
  @through = through
16
17
  @polymorphic = polymorphic
18
+ @reverse_label = nil
19
+ @reverse_association_type = nil
17
20
  end
18
21
 
19
22
  def to_h
@@ -24,7 +27,9 @@ module Rails
24
27
  label: @label,
25
28
  foreign_key: @foreign_key,
26
29
  through: @through,
27
- polymorphic: @polymorphic
30
+ polymorphic: @polymorphic,
31
+ reverse_label: @reverse_label,
32
+ reverse_association_type: @reverse_association_type
28
33
  }
29
34
  end
30
35
  end
@@ -6,18 +6,19 @@ module Rails
6
6
  module Schema
7
7
  module Transformer
8
8
  class GraphBuilder
9
- def initialize(column_reader: Extractor::ColumnReader.new, association_reader: Extractor::AssociationReader.new)
9
+ def initialize(column_reader: Extractor::ColumnReader.new, association_reader: Extractor::AssociationReader.new,
10
+ configuration: ::Rails::Schema.configuration)
10
11
  @column_reader = column_reader
11
12
  @association_reader = association_reader
13
+ @group_proc = configuration.resolved_group_proc
12
14
  end
13
15
 
14
16
  def build(models)
15
17
  model_ids = assign_unique_ids(models)
16
- name_to_id = {}
17
- model_ids.each { |m, uid| name_to_id[m.name] ||= uid }
18
+ name_to_id = build_name_to_id(model_ids)
18
19
 
19
20
  nodes = model_ids.map { |m, uid| build_node(m, uid) }
20
- edges = model_ids.flat_map { |m, uid| build_edges(m, uid, name_to_id) }
21
+ edges = deduplicate_edges(model_ids.flat_map { |m, uid| build_edges(m, uid, name_to_id) })
21
22
 
22
23
  {
23
24
  nodes: nodes.map(&:to_h),
@@ -28,6 +29,10 @@ module Rails
28
29
 
29
30
  private
30
31
 
32
+ def build_name_to_id(model_ids)
33
+ model_ids.each_with_object({}) { |(m, uid), map| map[m.name] ||= uid }
34
+ end
35
+
31
36
  def assign_unique_ids(models)
32
37
  counts = models.group_by(&:name).transform_values(&:size)
33
38
  models.map do |m|
@@ -37,10 +42,12 @@ module Rails
37
42
  end
38
43
 
39
44
  def build_node(model, unique_id)
45
+ group = @group_proc ? Array(@group_proc.call(model)) : []
40
46
  Node.new(
41
47
  id: unique_id,
42
48
  table_name: model.table_name,
43
- columns: @column_reader.read(model)
49
+ columns: @column_reader.read(model),
50
+ group: group
44
51
  )
45
52
  end
46
53
 
@@ -60,6 +67,51 @@ module Rails
60
67
  end
61
68
  end
62
69
 
70
+ def deduplicate_edges(edges)
71
+ edges = deduplicate_habtm(edges)
72
+ deduplicate_has_many_belongs_to(edges)
73
+ end
74
+
75
+ def deduplicate_habtm(edges)
76
+ habtm_first = {}
77
+
78
+ edges.each_with_object([]) do |edge, result|
79
+ next result << edge unless edge.association_type == "has_and_belongs_to_many"
80
+
81
+ pair = [edge.from, edge.to].sort
82
+ if habtm_first.key?(pair)
83
+ habtm_first[pair].reverse_label = edge.label
84
+ else
85
+ habtm_first[pair] = edge
86
+ result << edge
87
+ end
88
+ end
89
+ end
90
+
91
+ def deduplicate_has_many_belongs_to(edges)
92
+ hm_lookup = build_has_many_lookup(edges)
93
+
94
+ edges.each_with_object([]) do |edge, result|
95
+ if edge.association_type == "belongs_to"
96
+ hm_edge = hm_lookup[[edge.from, edge.to, edge.foreign_key]]
97
+ if hm_edge
98
+ hm_edge.reverse_label = edge.label
99
+ hm_edge.reverse_association_type = edge.association_type
100
+ next
101
+ end
102
+ end
103
+ result << edge
104
+ end
105
+ end
106
+
107
+ def build_has_many_lookup(edges)
108
+ edges.each_with_object({}) do |edge, lookup|
109
+ next unless %w[has_many has_one].include?(edge.association_type)
110
+
111
+ lookup[[edge.to, edge.from, edge.foreign_key]] = edge
112
+ end
113
+ end
114
+
63
115
  def build_metadata(models)
64
116
  {
65
117
  generated_at: Time.now.utc.iso8601,
@@ -4,20 +4,19 @@ module Rails
4
4
  module Schema
5
5
  module Transformer
6
6
  class Node
7
- attr_reader :id, :table_name, :columns
7
+ attr_reader :id, :table_name, :columns, :group
8
8
 
9
- def initialize(id:, table_name:, columns: [])
9
+ def initialize(id:, table_name:, columns: [], group: [])
10
10
  @id = id
11
11
  @table_name = table_name
12
12
  @columns = columns
13
+ @group = group
13
14
  end
14
15
 
15
16
  def to_h
16
- {
17
- id: @id,
18
- table_name: @table_name,
19
- columns: @columns
20
- }
17
+ h = { id: @id, table_name: @table_name, columns: @columns }
18
+ h[:group] = @group unless @group.empty?
19
+ h
21
20
  end
22
21
  end
23
22
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rails
4
4
  module Schema
5
- VERSION = "0.1.4"
5
+ VERSION = "0.1.6"
6
6
  end
7
7
  end
data/lib/rails/schema.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "schema/transformer/node"
6
6
  require_relative "schema/transformer/edge"
7
7
  require_relative "schema/extractor/schema_file_parser"
8
8
  require_relative "schema/extractor/structure_sql_parser"
9
+ require_relative "schema/extractor/packwerk_discovery"
9
10
  require_relative "schema/extractor/model_scanner"
10
11
  require_relative "schema/extractor/column_reader"
11
12
  require_relative "schema/extractor/association_reader"
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-schema
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Kislichenko
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-03-08 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activerecord
@@ -80,6 +79,7 @@ files:
80
79
  - lib/rails/schema/extractor/mongoid/column_reader.rb
81
80
  - lib/rails/schema/extractor/mongoid/model_adapter.rb
82
81
  - lib/rails/schema/extractor/mongoid/model_scanner.rb
82
+ - lib/rails/schema/extractor/packwerk_discovery.rb
83
83
  - lib/rails/schema/extractor/schema_file_parser.rb
84
84
  - lib/rails/schema/extractor/structure_sql_parser.rb
85
85
  - lib/rails/schema/railtie.rb
@@ -97,7 +97,6 @@ metadata:
97
97
  source_code_uri: https://github.com/andrew2net/rails-schema
98
98
  changelog_uri: https://github.com/andrew2net/rails-schema/blob/main/CHANGELOG.md
99
99
  rubygems_mfa_required: 'true'
100
- post_install_message:
101
100
  rdoc_options: []
102
101
  require_paths:
103
102
  - lib
@@ -112,8 +111,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
112
111
  - !ruby/object:Gem::Version
113
112
  version: '0'
114
113
  requirements: []
115
- rubygems_version: 3.1.6
116
- signing_key:
114
+ rubygems_version: 3.6.9
117
115
  specification_version: 4
118
116
  summary: Interactive HTML visualization of your Rails database schema
119
117
  test_files: []