rails-schema 0.1.2 → 0.1.4

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.
@@ -17,6 +17,9 @@
17
17
  --edge-has-many: #2ec4b6;
18
18
  --edge-has-one: #ff6b6b;
19
19
  --edge-habtm: #ffd166;
20
+ --edge-embeds-many: #a855f7;
21
+ --edge-embeds-one: #c084fc;
22
+ --edge-embedded-in: #a855f7;
20
23
  --shadow: 0 2px 8px rgba(0,0,0,0.1);
21
24
  --sidebar-width: 280px;
22
25
  --detail-width: 320px;
@@ -42,6 +45,9 @@
42
45
  --edge-has-many: #2ec4b6;
43
46
  --edge-has-one: #ff6b6b;
44
47
  --edge-habtm: #ffd166;
48
+ --edge-embeds-many: #a855f7;
49
+ --edge-embeds-one: #c084fc;
50
+ --edge-embedded-in: #a855f7;
45
51
  --shadow: 0 2px 8px rgba(0,0,0,0.3);
46
52
  }
47
53
 
@@ -65,6 +71,9 @@
65
71
  --edge-has-many: #2ec4b6;
66
72
  --edge-has-one: #ff6b6b;
67
73
  --edge-habtm: #ffd166;
74
+ --edge-embeds-many: #a855f7;
75
+ --edge-embeds-one: #c084fc;
76
+ --edge-embedded-in: #a855f7;
68
77
  --shadow: 0 2px 8px rgba(0,0,0,0.3);
69
78
  }
70
79
  }
@@ -318,6 +327,9 @@ body {
318
327
  .edge-line.has_many { stroke: var(--edge-has-many); }
319
328
  .edge-line.has_one { stroke: var(--edge-has-one); }
320
329
  .edge-line.has_and_belongs_to_many { stroke: var(--edge-habtm); }
330
+ .edge-line.embeds_many { stroke: var(--edge-embeds-many); stroke-dasharray: 4 2; }
331
+ .edge-line.embeds_one { stroke: var(--edge-embeds-one); stroke-dasharray: 4 2; }
332
+ .edge-line.embedded_in { stroke: var(--edge-embedded-in); stroke-dasharray: 4 2; }
321
333
 
322
334
  .edge-line.through {
323
335
  stroke-dasharray: 6 3;
@@ -334,6 +346,13 @@ body {
334
346
  pointer-events: none;
335
347
  }
336
348
 
349
+ .edge-label-bg {
350
+ fill: var(--bg-primary);
351
+ opacity: 0.85;
352
+ rx: 2;
353
+ pointer-events: none;
354
+ }
355
+
337
356
  /* Faded state for non-focused elements */
338
357
  .faded { opacity: 0.15; transition: opacity 0.3s; }
339
358
  .highlighted { transition: opacity 0.3s; }
@@ -356,19 +375,35 @@ body {
356
375
 
357
376
  #detail-content {
358
377
  padding: 16px;
378
+ padding-top: calc(var(--toolbar-height) + 16px);
359
379
  width: var(--detail-width);
360
380
  }
361
381
 
382
+ .detail-header {
383
+ display: flex;
384
+ align-items: center;
385
+ position: relative;
386
+ padding-right: 28px;
387
+ }
388
+
362
389
  #detail-content h2 {
390
+ flex: 1;
391
+ min-width: 0;
363
392
  font-size: 18px;
364
393
  font-weight: 700;
365
394
  margin-bottom: 4px;
395
+ overflow: hidden;
396
+ text-overflow: ellipsis;
397
+ white-space: nowrap;
366
398
  }
367
399
 
368
400
  #detail-content .detail-table {
369
401
  font-size: 12px;
370
402
  color: var(--text-secondary);
371
403
  margin-bottom: 16px;
404
+ overflow: hidden;
405
+ text-overflow: ellipsis;
406
+ white-space: nowrap;
372
407
  }
373
408
 
374
409
  #detail-content h3 {
@@ -437,15 +472,16 @@ body {
437
472
  }
438
473
 
439
474
  #detail-close {
440
- position: absolute;
441
- top: 12px;
442
- right: 12px;
443
475
  background: none;
444
476
  border: none;
445
477
  color: var(--text-secondary);
446
478
  cursor: pointer;
447
479
  font-size: 18px;
448
480
  padding: 4px;
481
+ flex-shrink: 0;
482
+ position: absolute;
483
+ top: -8px;
484
+ right: 0;
449
485
  }
450
486
 
451
487
  /* Legend */
@@ -473,6 +509,22 @@ body {
473
509
  .legend-line.has_many { background: var(--edge-has-many); }
474
510
  .legend-line.has_one { background: var(--edge-has-one); }
475
511
  .legend-line.habtm { background: var(--edge-habtm); }
512
+ .legend-line.embeds_many {
513
+ background-image: repeating-linear-gradient(
514
+ to right,
515
+ var(--edge-embeds-many) 0, var(--edge-embeds-many) 4px,
516
+ transparent 4px, transparent 6px
517
+ );
518
+ background-color: transparent;
519
+ }
520
+ .legend-line.embeds_one {
521
+ background-image: repeating-linear-gradient(
522
+ to right,
523
+ var(--edge-embeds-one) 0, var(--edge-embeds-one) 4px,
524
+ transparent 4px, transparent 6px
525
+ );
526
+ background-color: transparent;
527
+ }
476
528
 
477
529
  .legend-line.through {
478
530
  background: var(--edge-color);
@@ -26,7 +26,9 @@
26
26
  <span class="legend-item"><span class="legend-line has_many"></span> has_many (1:M)</span>
27
27
  <span class="legend-item"><span class="legend-line has_one"></span> has_one (1:1)</span>
28
28
  <span class="legend-item"><span class="legend-line habtm"></span> habtm (M:M)</span>
29
- <span class="legend-item"><span class="legend-line through"></span> :through</span>
29
+ <span class="legend-item" data-mode="mongoid"><span class="legend-line embeds_many"></span> embeds_many</span>
30
+ <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>
30
32
  <span class="legend-item"><span class="legend-line polymorphic"></span> polymorphic</span>
31
33
  </div>
32
34
  <div class="spacer"></div>
@@ -6,7 +6,7 @@ module Rails
6
6
  class ModelScanner
7
7
  def initialize(configuration: ::Rails::Schema.configuration, schema_data: nil)
8
8
  @configuration = configuration
9
- @schema_data = schema_data
9
+ @schema_data = schema_data.nil? || schema_data.empty? ? nil : schema_data
10
10
  end
11
11
 
12
12
  def scan
@@ -43,7 +43,7 @@ module Rails
43
43
  else
44
44
  loader.eager_load
45
45
  end
46
- rescue StandardError => e
46
+ rescue StandardError, LoadError => e
47
47
  warn "[rails-schema] Zeitwerk eager_load failed (#{e.class}: #{e.message}), " \
48
48
  "trying Rails.application.eager_load!"
49
49
  eager_load_via_application!
@@ -51,7 +51,7 @@ module Rails
51
51
 
52
52
  def eager_load_via_application!
53
53
  ::Rails.application.eager_load!
54
- rescue StandardError => e
54
+ rescue StandardError, LoadError => e
55
55
  warn "[rails-schema] eager_load! failed (#{e.class}: #{e.message}), " \
56
56
  "falling back to per-file model loading"
57
57
  eager_load_model_files!
@@ -63,9 +63,9 @@ module Rails
63
63
  models_path = ::Rails.root.join("app", "models")
64
64
  return unless models_path.exist?
65
65
 
66
- Dir.glob(models_path.join("**/*.rb")).each do |file|
66
+ Dir.glob(models_path.join("**/*.rb")).sort.each do |file|
67
67
  require file
68
- rescue StandardError => e
68
+ rescue StandardError, LoadError => e
69
69
  warn "[rails-schema] Could not load #{file}: #{e.class}: #{e.message}"
70
70
  end
71
71
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Schema
5
+ module Extractor
6
+ module Mongoid
7
+ class AssociationReader
8
+ ASSOCIATION_TYPE_MAP = {
9
+ "Mongoid::Association::Referenced::HasMany" => "has_many",
10
+ "Mongoid::Association::Referenced::HasOne" => "has_one",
11
+ "Mongoid::Association::Referenced::BelongsTo" => "belongs_to",
12
+ "Mongoid::Association::Referenced::HasAndBelongsToMany" => "has_and_belongs_to_many",
13
+ "Mongoid::Association::Embedded::EmbedsMany" => "embeds_many",
14
+ "Mongoid::Association::Embedded::EmbedsOne" => "embeds_one",
15
+ "Mongoid::Association::Embedded::EmbeddedIn" => "embedded_in"
16
+ }.freeze
17
+
18
+ def read(model)
19
+ model.relations.filter_map do |name, metadata|
20
+ next if skip_association?(metadata)
21
+
22
+ build_association_data(model, name, metadata)
23
+ end
24
+ rescue StandardError => e
25
+ warn "[rails-schema] Could not read Mongoid relations for #{model.name}: #{e.class}: #{e.message}"
26
+ []
27
+ end
28
+
29
+ private
30
+
31
+ def skip_association?(metadata)
32
+ association_type_string(metadata) == "belongs_to" && metadata.polymorphic?
33
+ end
34
+
35
+ def build_association_data(model, name, metadata)
36
+ {
37
+ from: model.name,
38
+ to: metadata.class_name,
39
+ association_type: association_type_string(metadata),
40
+ label: name.to_s,
41
+ foreign_key: metadata.respond_to?(:foreign_key) ? metadata.foreign_key&.to_s : nil,
42
+ through: nil,
43
+ polymorphic: metadata.respond_to?(:as) && metadata.as ? true : false
44
+ }
45
+ rescue StandardError => e
46
+ warn "[rails-schema] Could not read Mongoid association #{name} on #{model.name}: " \
47
+ "#{e.class}: #{e.message}"
48
+ nil
49
+ end
50
+
51
+ def association_type_string(metadata)
52
+ # metadata.class returns the association class (e.g. Mongoid::Association::Referenced::HasMany)
53
+ ASSOCIATION_TYPE_MAP.fetch(metadata.class.name, metadata.class.name)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Schema
5
+ module Extractor
6
+ module Mongoid
7
+ class ColumnReader
8
+ TYPE_MAP = {
9
+ "String" => "string",
10
+ "Integer" => "integer",
11
+ "Float" => "float",
12
+ "BigDecimal" => "decimal",
13
+ "Date" => "date",
14
+ "Time" => "datetime",
15
+ "DateTime" => "datetime",
16
+ "Array" => "array",
17
+ "Hash" => "hash",
18
+ "Regexp" => "regexp",
19
+ "Symbol" => "symbol",
20
+ "Range" => "range",
21
+ "BSON::ObjectId" => "object_id",
22
+ "Mongoid::Boolean" => "boolean",
23
+ "TrueClass" => "boolean",
24
+ "FalseClass" => "boolean"
25
+ }.freeze
26
+
27
+ def read(model)
28
+ model.fields.map do |name, field|
29
+ {
30
+ name: name,
31
+ type: map_type(field.type),
32
+ nullable: !required_field?(model, name),
33
+ primary: name == "_id",
34
+ default: format_default(field.default_val)
35
+ }
36
+ end
37
+ rescue StandardError => e
38
+ warn "[rails-schema] Could not read Mongoid fields for #{model.name}: #{e.class}: #{e.message}"
39
+ []
40
+ end
41
+
42
+ private
43
+
44
+ def required_field?(model, field_name)
45
+ return true if field_name == "_id"
46
+ return false unless model.respond_to?(:validators_on)
47
+
48
+ model.validators_on(field_name).any? do |v|
49
+ v.class.name.include?("PresenceValidator")
50
+ end
51
+ end
52
+
53
+ def map_type(type)
54
+ return "object" if type.nil?
55
+
56
+ TYPE_MAP.fetch(type.name, type.name.downcase)
57
+ end
58
+
59
+ def format_default(default)
60
+ case default
61
+ when Proc
62
+ "(dynamic)"
63
+ else
64
+ default
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Schema
5
+ module Extractor
6
+ module Mongoid
7
+ class ModelAdapter
8
+ attr_reader :model
9
+
10
+ def initialize(model)
11
+ @model = model
12
+ end
13
+
14
+ def name
15
+ model.name
16
+ end
17
+
18
+ def table_name
19
+ model.collection_name.to_s
20
+ end
21
+
22
+ def respond_to_missing?(method, include_private = false)
23
+ model.respond_to?(method, include_private) || super
24
+ end
25
+
26
+ def method_missing(method, *args, **kwargs, &block)
27
+ if model.respond_to?(method)
28
+ model.send(method, *args, **kwargs, &block)
29
+ else
30
+ super
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Schema
5
+ module Extractor
6
+ module Mongoid
7
+ class ModelScanner
8
+ def initialize(configuration: ::Rails::Schema.configuration)
9
+ @configuration = configuration
10
+ end
11
+
12
+ def scan
13
+ eager_load_models!
14
+
15
+ candidates = ObjectSpace.each_object(Class).select do |klass|
16
+ klass.include?(::Mongoid::Document)
17
+ rescue StandardError
18
+ false
19
+ end
20
+
21
+ named = candidates.reject { |m| m.name.nil? }
22
+ included = named.reject { |m| excluded?(m) }
23
+
24
+ included.sort_by(&:name)
25
+ end
26
+
27
+ private
28
+
29
+ def eager_load_models!
30
+ return unless defined?(::Rails.application) && ::Rails.application
31
+
32
+ if zeitwerk_available?
33
+ eager_load_via_zeitwerk!
34
+ else
35
+ eager_load_via_application!
36
+ end
37
+
38
+ eager_load_engine_models!
39
+ end
40
+
41
+ def eager_load_via_zeitwerk!
42
+ loader = ::Rails.autoloaders.main
43
+ models_path = ::Rails.root&.join("app", "models")&.to_s
44
+
45
+ if models_path && File.directory?(models_path) && loader.respond_to?(:eager_load_dir)
46
+ loader.eager_load_dir(models_path)
47
+ else
48
+ loader.eager_load
49
+ end
50
+ rescue StandardError => e
51
+ warn "[rails-schema] Zeitwerk eager_load failed (#{e.class}: #{e.message}), " \
52
+ "trying Rails.application.eager_load!"
53
+ eager_load_via_application!
54
+ end
55
+
56
+ def eager_load_via_application!
57
+ ::Rails.application.eager_load!
58
+ rescue StandardError => e
59
+ warn "[rails-schema] eager_load! failed (#{e.class}: #{e.message}), " \
60
+ "falling back to per-file model loading"
61
+ eager_load_model_files!
62
+ end
63
+
64
+ def eager_load_model_files!
65
+ return unless defined?(::Rails.root) && ::Rails.root
66
+
67
+ models_path = ::Rails.root.join("app", "models")
68
+ return unless models_path.exist?
69
+
70
+ Dir.glob(models_path.join("**/*.rb")).sort.each do |file|
71
+ require file
72
+ rescue StandardError => e
73
+ warn "[rails-schema] Could not load #{file}: #{e.class}: #{e.message}"
74
+ end
75
+ end
76
+
77
+ def eager_load_engine_models!
78
+ return unless defined?(::Rails::Engine)
79
+
80
+ ::Rails::Engine.subclasses.each do |engine_class|
81
+ next if engine_class <= ::Rails::Application
82
+
83
+ engine = engine_class.instance
84
+ next unless engine
85
+
86
+ eager_load_engine(engine)
87
+ rescue StandardError => e
88
+ warn "[rails-schema] Could not eager-load engine #{engine_class}: #{e.class}: #{e.message}"
89
+ end
90
+ end
91
+
92
+ def eager_load_engine(engine)
93
+ models_paths = engine.paths["app/models"]&.existent || []
94
+ return if models_paths.empty?
95
+
96
+ if zeitwerk_available?
97
+ eager_load_engine_zeitwerk(models_paths)
98
+ else
99
+ eager_load_engine_files(models_paths)
100
+ end
101
+ end
102
+
103
+ def zeitwerk_available?
104
+ defined?(::Rails.autoloaders) && ::Rails.autoloaders.respond_to?(:main)
105
+ end
106
+
107
+ def eager_load_engine_zeitwerk(paths)
108
+ loader = ::Rails.autoloaders.main
109
+ paths.each { |path| loader.eager_load_dir(path) if loader.respond_to?(:eager_load_dir) }
110
+ end
111
+
112
+ def eager_load_engine_files(paths)
113
+ paths.each do |path|
114
+ Dir.glob(File.join(path, "**/*.rb")).sort.each do |file|
115
+ require file
116
+ rescue StandardError => e
117
+ warn "[rails-schema] Could not load engine model #{file}: #{e.class}: #{e.message}"
118
+ end
119
+ end
120
+ end
121
+
122
+ def excluded?(model)
123
+ @configuration.exclude_models.any? do |pattern|
124
+ if pattern.end_with?("*")
125
+ model.name.start_with?(pattern.delete_suffix("*"))
126
+ else
127
+ model.name == pattern
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -19,9 +19,9 @@ module Rails
19
19
  }.freeze
20
20
 
21
21
  COMPOUND_TYPE_RE = /\A(character\s+varying|bit\s+varying|double\s+precision|
22
- timestamp(?:\(\d+\))?\s+with(?:out)?\s+time\s+zone)/ix
23
- CONSTRAINT_RE = /\A(CONSTRAINT|UNIQUE|CHECK|EXCLUDE|FOREIGN\s+KEY)\b/i
24
- PK_CONSTRAINT_RE = /PRIMARY\s+KEY\s*\(([^)]+)\)/i
22
+ timestamp(?:\(\d+\))?\s+with(?:out)?\s+time\s+zone)/ix.freeze
23
+ CONSTRAINT_RE = /\A(CONSTRAINT|UNIQUE|CHECK|EXCLUDE|FOREIGN\s+KEY)\b/i.freeze
24
+ PK_CONSTRAINT_RE = /PRIMARY\s+KEY\s*\(([^)]+)\)/i.freeze
25
25
 
26
26
  def initialize(structure_path = nil)
27
27
  @structure_path = structure_path
@@ -56,7 +56,9 @@ module Rails
56
56
  File.join(Dir.pwd, "db", "structure.sql")
57
57
  end
58
58
 
59
- def unquote(identifier) = identifier.delete('"')
59
+ def unquote(identifier)
60
+ identifier.delete('"')
61
+ end
60
62
 
61
63
  def extract_table_name(raw)
62
64
  unquote(raw).split(".").last
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module Rails
4
6
  module Schema
5
7
  module Transformer
@@ -10,9 +12,12 @@ module Rails
10
12
  end
11
13
 
12
14
  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) }
15
+ model_ids = assign_unique_ids(models)
16
+ name_to_id = {}
17
+ model_ids.each { |m, uid| name_to_id[m.name] ||= uid }
18
+
19
+ 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) }
16
21
 
17
22
  {
18
23
  nodes: nodes.map(&:to_h),
@@ -23,21 +28,29 @@ module Rails
23
28
 
24
29
  private
25
30
 
26
- def build_node(model)
31
+ def assign_unique_ids(models)
32
+ counts = models.group_by(&:name).transform_values(&:size)
33
+ models.map do |m|
34
+ uid = counts[m.name] > 1 ? "#{m.name} (#{m.table_name})" : m.name
35
+ [m, uid]
36
+ end
37
+ end
38
+
39
+ def build_node(model, unique_id)
27
40
  Node.new(
28
- id: model.name,
41
+ id: unique_id,
29
42
  table_name: model.table_name,
30
43
  columns: @column_reader.read(model)
31
44
  )
32
45
  end
33
46
 
34
- def build_edges(model, node_names)
47
+ def build_edges(model, unique_id, name_to_id)
35
48
  @association_reader.read(model).filter_map do |assoc|
36
- next unless node_names.include?(assoc[:to])
49
+ next unless name_to_id.key?(assoc[:to])
37
50
 
38
51
  Edge.new(
39
- from: assoc[:from],
40
- to: assoc[:to],
52
+ from: unique_id,
53
+ to: name_to_id[assoc[:to]],
41
54
  association_type: assoc[:association_type],
42
55
  label: assoc[:label],
43
56
  foreign_key: assoc[:foreign_key],
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rails
4
4
  module Schema
5
- VERSION = "0.1.2"
5
+ VERSION = "0.1.4"
6
6
  end
7
7
  end
data/lib/rails/schema.rb CHANGED
@@ -30,15 +30,58 @@ module Rails
30
30
  end
31
31
 
32
32
  def generate(output: nil)
33
+ if mongoid_mode?
34
+ generate_mongoid(output: output)
35
+ else
36
+ generate_active_record(output: output)
37
+ end
38
+ end
39
+
40
+ def mongoid_mode?
41
+ case configuration.schema_format
42
+ when :mongoid
43
+ true
44
+ when :auto
45
+ defined?(::Mongoid::Document) ? true : false
46
+ else
47
+ false
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def generate_active_record(output:)
33
54
  schema_data = parse_schema
34
55
  models = Extractor::ModelScanner.new(schema_data: schema_data).scan
35
56
  column_reader = Extractor::ColumnReader.new(schema_data: schema_data)
36
57
  graph_data = Transformer::GraphBuilder.new(column_reader: column_reader).build(models)
58
+ graph_data[:metadata][:mode] = "active_record"
37
59
  generator = Renderer::HtmlGenerator.new(graph_data: graph_data)
38
60
  generator.render_to_file(output)
39
61
  end
40
62
 
41
- private
63
+ def require_mongoid_extractors
64
+ require_relative "schema/extractor/mongoid/model_scanner"
65
+ require_relative "schema/extractor/mongoid/model_adapter"
66
+ require_relative "schema/extractor/mongoid/column_reader"
67
+ require_relative "schema/extractor/mongoid/association_reader"
68
+ end
69
+
70
+ def generate_mongoid(output:)
71
+ require_mongoid_extractors
72
+
73
+ raw_models = Extractor::Mongoid::ModelScanner.new.scan
74
+ models = raw_models.map { |m| Extractor::Mongoid::ModelAdapter.new(m) }
75
+ column_reader = Extractor::Mongoid::ColumnReader.new
76
+ association_reader = Extractor::Mongoid::AssociationReader.new
77
+ graph_data = Transformer::GraphBuilder.new(
78
+ column_reader: column_reader,
79
+ association_reader: association_reader
80
+ ).build(models)
81
+ graph_data[:metadata][:mode] = "mongoid"
82
+ generator = Renderer::HtmlGenerator.new(graph_data: graph_data)
83
+ generator.render_to_file(output)
84
+ end
42
85
 
43
86
  def parse_schema
44
87
  case configuration.schema_format