rails-schema 0.1.5 → 0.1.7

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.
@@ -142,11 +142,65 @@ body {
142
142
  margin: 0 4px;
143
143
  }
144
144
 
145
+ #toolbar label.toolbar-label {
146
+ display: flex;
147
+ align-items: center;
148
+ gap: 4px;
149
+ font-size: 13px;
150
+ color: var(--text-secondary);
151
+ cursor: pointer;
152
+ white-space: nowrap;
153
+ }
154
+
155
+ #toolbar label.toolbar-label input[type="checkbox"] {
156
+ margin: 0;
157
+ cursor: pointer;
158
+ accent-color: var(--accent);
159
+ }
160
+
145
161
  #toolbar .shortcuts-hint {
146
162
  font-size: 11px;
147
163
  color: var(--text-muted);
148
164
  }
149
165
 
166
+ .toolbar-dropdown {
167
+ position: relative;
168
+ }
169
+
170
+ .toolbar-dropdown-menu {
171
+ display: none;
172
+ position: absolute;
173
+ top: 100%;
174
+ right: 0;
175
+ margin-top: 4px;
176
+ background: var(--bg-primary);
177
+ border: 1px solid var(--border-color);
178
+ border-radius: 6px;
179
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
180
+ z-index: 300;
181
+ min-width: 160px;
182
+ }
183
+
184
+ .toolbar-dropdown-menu.open {
185
+ display: flex;
186
+ flex-direction: column;
187
+ }
188
+
189
+ .toolbar-dropdown-menu button {
190
+ border: none;
191
+ border-radius: 0;
192
+ text-align: left;
193
+ padding: 8px 12px;
194
+ }
195
+
196
+ .toolbar-dropdown-menu button:first-child {
197
+ border-radius: 6px 6px 0 0;
198
+ }
199
+
200
+ .toolbar-dropdown-menu button:last-of-type {
201
+ border-radius: 0 0 6px 6px;
202
+ }
203
+
150
204
  /* Sidebar */
151
205
  #sidebar {
152
206
  width: var(--sidebar-width);
@@ -232,6 +286,10 @@ body {
232
286
  background: var(--bg-tertiary);
233
287
  }
234
288
 
289
+ #collapse-groups-btn {
290
+ margin-left: auto;
291
+ }
292
+
235
293
  #model-list {
236
294
  flex: 1;
237
295
  overflow-y: auto;
@@ -545,6 +603,15 @@ body {
545
603
  gap: 4px;
546
604
  }
547
605
 
606
+ .legend-toggle {
607
+ cursor: pointer;
608
+ }
609
+
610
+ .legend-toggle input[type="checkbox"] {
611
+ margin: 0;
612
+ cursor: pointer;
613
+ }
614
+
548
615
  .legend-line {
549
616
  width: 20px;
550
617
  height: 2px;
@@ -591,3 +658,59 @@ body {
591
658
  );
592
659
  background-color: transparent;
593
660
  }
661
+
662
+ /* Sidebar group headers */
663
+ .group-header {
664
+ display: flex;
665
+ align-items: center;
666
+ padding: 6px 16px;
667
+ font-size: 12px;
668
+ font-weight: 700;
669
+ border-bottom: 1px solid var(--border-color);
670
+ color: var(--text-secondary);
671
+ text-transform: uppercase;
672
+ letter-spacing: 0.5px;
673
+ cursor: pointer;
674
+ user-select: none;
675
+ gap: 6px;
676
+ }
677
+
678
+ .group-header:hover {
679
+ background: var(--bg-tertiary);
680
+ }
681
+
682
+ .group-header.active {
683
+ background: var(--accent-light);
684
+ }
685
+
686
+ .group-count {
687
+ font-size: 11px;
688
+ color: var(--text-secondary);
689
+ font-weight: normal;
690
+ margin-left: auto;
691
+ flex-shrink: 0;
692
+ }
693
+
694
+ .group-toggle {
695
+ font-size: 10px;
696
+ width: 12px;
697
+ flex-shrink: 0;
698
+ }
699
+
700
+ .group-color-dot {
701
+ width: 10px;
702
+ height: 10px;
703
+ border-radius: 50%;
704
+ display: inline-block;
705
+ flex-shrink: 0;
706
+ }
707
+
708
+ .group-header-label {
709
+ overflow: hidden;
710
+ text-overflow: ellipsis;
711
+ white-space: nowrap;
712
+ }
713
+
714
+ .group-models {
715
+ border-bottom: 1px solid var(--border-color);
716
+ }
@@ -19,6 +19,7 @@
19
19
  <div id="sidebar-actions">
20
20
  <button id="select-all-btn">Select All</button>
21
21
  <button id="deselect-all-btn">Deselect All</button>
22
+ <button id="collapse-groups-btn" style="display:none" title="Collapse all groups">&#9660;</button>
22
23
  </div>
23
24
  <div id="model-list"></div>
24
25
  </div>
@@ -31,7 +32,7 @@
31
32
  <span class="legend-item"><span class="legend-line habtm"></span> habtm (M:M)</span>
32
33
  <span class="legend-item" data-mode="mongoid"><span class="legend-line embeds_many"></span> embeds_many</span>
33
34
  <span class="legend-item" data-mode="mongoid"><span class="legend-line embeds_one"></span> embeds_one</span>
34
- <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>
35
36
  <span class="legend-item"><span class="legend-line polymorphic"></span> polymorphic</span>
36
37
  </div>
37
38
  <div class="spacer"></div>
@@ -41,7 +42,17 @@
41
42
  <button id="fit-btn" title="Fit to screen (F)">Fit</button>
42
43
  <button id="theme-btn" title="Toggle theme">Theme</button>
43
44
  <span class="toolbar-divider"></span>
44
- <button id="export-mermaid-btn" title="Export as Mermaid (Markdown)">Export Mermaid</button>
45
+ <label class="toolbar-label" for="manual-positioning"><input type="checkbox" id="manual-positioning">Manual Positioning</label>
46
+ <span class="toolbar-divider"></span>
47
+ <div class="toolbar-dropdown">
48
+ <button id="file-menu-btn">File &#9660;</button>
49
+ <div class="toolbar-dropdown-menu" id="file-menu">
50
+ <button id="export-mermaid-btn">Export Mermaid</button>
51
+ <button id="save-layout-btn">Save Layout</button>
52
+ <button id="load-layout-btn">Load Layout</button>
53
+ <input type="file" id="load-layout-input" accept=".json" style="display:none">
54
+ </div>
55
+ </div>
45
56
  <span class="shortcuts-hint">/ search · Esc deselect · F fit</span>
46
57
  </div>
47
58
 
@@ -3,7 +3,8 @@
3
3
  module Rails
4
4
  module Schema
5
5
  class Configuration
6
- attr_accessor :output_path, :exclude_models, :exclude_model_if, :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"
@@ -13,6 +14,20 @@ module Rails
13
14
  @theme = :auto
14
15
  @expand_columns = false
15
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
16
31
  end
17
32
  end
18
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)
@@ -89,11 +92,7 @@ module Rails
89
92
  return true if @configuration.exclude_model_if&.call(model)
90
93
 
91
94
  @configuration.exclude_models.any? do |pattern|
92
- if pattern.end_with?("*")
93
- model.name.start_with?(pattern.delete_suffix("*"))
94
- else
95
- model.name == pattern
96
- end
95
+ pattern.end_with?("*") ? model.name.start_with?(pattern.delete_suffix("*")) : model.name == pattern
97
96
  end
98
97
  end
99
98
 
@@ -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
@@ -6,9 +6,11 @@ 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)
@@ -40,10 +42,12 @@ module Rails
40
42
  end
41
43
 
42
44
  def build_node(model, unique_id)
45
+ group = @group_proc ? Array(@group_proc.call(model)) : []
43
46
  Node.new(
44
47
  id: unique_id,
45
48
  table_name: model.table_name,
46
- columns: @column_reader.read(model)
49
+ columns: @column_reader.read(model),
50
+ group: group
47
51
  )
48
52
  end
49
53
 
@@ -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.5"
5
+ VERSION = "0.1.7"
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.5
4
+ version: 0.1.7
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-15 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: []