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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/CLAUDE.md +107 -0
- data/README.md +29 -5
- data/lib/rails/schema/assets/app.js +237 -13
- data/lib/rails/schema/assets/style.css +55 -3
- data/lib/rails/schema/assets/template.html.erb +3 -1
- data/lib/rails/schema/extractor/model_scanner.rb +5 -5
- data/lib/rails/schema/extractor/mongoid/association_reader.rb +59 -0
- data/lib/rails/schema/extractor/mongoid/column_reader.rb +71 -0
- data/lib/rails/schema/extractor/mongoid/model_adapter.rb +37 -0
- data/lib/rails/schema/extractor/mongoid/model_scanner.rb +135 -0
- data/lib/rails/schema/extractor/structure_sql_parser.rb +6 -4
- data/lib/rails/schema/transformer/graph_builder.rb +22 -9
- data/lib/rails/schema/version.rb +1 -1
- data/lib/rails/schema.rb +44 -1
- metadata +34 -13
- data/PROJECT.md +0 -348
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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:
|
|
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,
|
|
47
|
+
def build_edges(model, unique_id, name_to_id)
|
|
35
48
|
@association_reader.read(model).filter_map do |assoc|
|
|
36
|
-
next unless
|
|
49
|
+
next unless name_to_id.key?(assoc[:to])
|
|
37
50
|
|
|
38
51
|
Edge.new(
|
|
39
|
-
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],
|
data/lib/rails/schema/version.rb
CHANGED
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
|
-
|
|
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
|