rails-erd 2.0.2 → 2.2.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.
- checksums.yaml +4 -4
- data/CHANGES.md +351 -0
- data/LICENSE.md +19 -0
- data/README.md +232 -12
- data/Rakefile +1 -1
- data/lib/rails_erd/cli.rb +21 -9
- data/lib/rails_erd/config.rb +6 -1
- data/lib/rails_erd/diagram/mermaid.rb +86 -21
- data/lib/rails_erd/diagram/tbls.rb +168 -0
- data/lib/rails_erd/diagram.rb +134 -17
- data/lib/rails_erd/domain.rb +38 -4
- data/lib/rails_erd/version.rb +1 -1
- data/lib/rails_erd.rb +2 -0
- data/test/test_helper.rb +1 -1
- data/test/unit/config_test.rb +11 -0
- data/test/unit/diagram_test.rb +191 -1
- data/test/unit/domain_test.rb +104 -0
- data/test/unit/mermaid_test.rb +142 -3
- data/test/unit/tbls_test.rb +152 -0
- metadata +8 -17
data/Rakefile
CHANGED
|
@@ -9,7 +9,7 @@ Rake::TestTask.new do |test|
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
YARD::Rake::YardocTask.new do |yard|
|
|
12
|
-
yard.files = ["lib/**/*.rb", "-", "LICENSE", "CHANGES.md"]
|
|
12
|
+
yard.files = ["lib/**/*.rb", "-", "LICENSE.md", "CHANGES.md"]
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
desc "Generate diagrams for bundled examples"
|
data/lib/rails_erd/cli.rb
CHANGED
|
@@ -9,7 +9,7 @@ Choice.options do
|
|
|
9
9
|
|
|
10
10
|
option :generator do
|
|
11
11
|
long "--generator=Generator"
|
|
12
|
-
desc "Generator to use (mermaid or
|
|
12
|
+
desc "Generator to use (mermaid, graphviz, or tbls). Defaults to mermaid."
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
option :mermaid_style do
|
|
@@ -47,6 +47,11 @@ Choice.options do
|
|
|
47
47
|
desc "Display polymorphic and abstract entities."
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
option :no_recursive do
|
|
51
|
+
long "--no-recursive"
|
|
52
|
+
desc "Omit self-referential relationships (an entity related to itself)."
|
|
53
|
+
end
|
|
54
|
+
|
|
50
55
|
option :no_indirect do
|
|
51
56
|
long "--direct"
|
|
52
57
|
desc "Omit indirect relationships (through other entities)."
|
|
@@ -72,6 +77,11 @@ Choice.options do
|
|
|
72
77
|
desc "Filter to exclude listed models in diagram."
|
|
73
78
|
end
|
|
74
79
|
|
|
80
|
+
option :only_attributes do
|
|
81
|
+
long "--only_attributes=MODEL.ATTRIBUTE,..."
|
|
82
|
+
desc "Show only the listed attributes for a model, e.g. Book.title,Book.isbn. Models that are not listed keep all their attributes."
|
|
83
|
+
end
|
|
84
|
+
|
|
75
85
|
option :exclude_attributes do
|
|
76
86
|
long "--exclude_attributes=MODEL[.ATTRIBUTE],..."
|
|
77
87
|
desc "Hide attributes per model. Use Model to hide all its attributes or Model.attribute to hide one, e.g. BigTable,User.password_digest."
|
|
@@ -193,10 +203,11 @@ module RailsERD
|
|
|
193
203
|
def initialize(path, options)
|
|
194
204
|
@path, @options = path, options
|
|
195
205
|
generator = options[:generator] || RailsERD.options[:generator]
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
206
|
+
case generator
|
|
207
|
+
when :mermaid then require "rails_erd/diagram/mermaid"
|
|
208
|
+
when :tbls then require "rails_erd/diagram/tbls"
|
|
209
|
+
when :graphviz then require "rails_erd/diagram/graphviz"
|
|
210
|
+
else require "rails_erd/diagram/mermaid"
|
|
200
211
|
end
|
|
201
212
|
end
|
|
202
213
|
|
|
@@ -236,10 +247,11 @@ module RailsERD
|
|
|
236
247
|
|
|
237
248
|
def generator
|
|
238
249
|
generator_type = options[:generator] || RailsERD.options[:generator]
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
250
|
+
case generator_type
|
|
251
|
+
when :mermaid then RailsERD::Diagram::Mermaid
|
|
252
|
+
when :tbls then RailsERD::Diagram::Tbls
|
|
253
|
+
when :graphviz then RailsERD::Diagram::Graphviz
|
|
254
|
+
else RailsERD::Diagram::Mermaid
|
|
243
255
|
end
|
|
244
256
|
end
|
|
245
257
|
|
data/lib/rails_erd/config.rb
CHANGED
|
@@ -73,6 +73,11 @@ module RailsERD
|
|
|
73
73
|
when :only, :exclude
|
|
74
74
|
Array(value).join(",").split(",").map { |v| v.strip }
|
|
75
75
|
|
|
76
|
+
# nil | { <string> => [<string>] }
|
|
77
|
+
when :only_attributes
|
|
78
|
+
require "rails_erd/diagram"
|
|
79
|
+
RailsERD::Diagram.normalize_only_attributes(value)
|
|
80
|
+
|
|
76
81
|
# nil | { <string> => true | [<string>] }
|
|
77
82
|
when :exclude_attributes
|
|
78
83
|
require "rails_erd/diagram"
|
|
@@ -80,7 +85,7 @@ module RailsERD
|
|
|
80
85
|
|
|
81
86
|
# true | false
|
|
82
87
|
when :disconnected, :indirect, :inheritance, :markup, :polymorphism,
|
|
83
|
-
:warn, :cluster
|
|
88
|
+
:recursive, :warn, :cluster
|
|
84
89
|
!!value
|
|
85
90
|
|
|
86
91
|
# nil | <string>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "rails_erd/diagram"
|
|
4
4
|
require "erb"
|
|
5
|
+
require "set"
|
|
5
6
|
|
|
6
7
|
module RailsERD
|
|
7
8
|
class Diagram
|
|
@@ -11,13 +12,25 @@ module RailsERD
|
|
|
11
12
|
|
|
12
13
|
setup do
|
|
13
14
|
self.graph = [diagram_type]
|
|
15
|
+
@drawn_entity_names = Set.new
|
|
14
16
|
|
|
15
17
|
# Respect orientation option: horizontal = TB (top-down), vertical = LR (left-right)
|
|
16
18
|
direction = (options.orientation.to_s == "vertical") ? "LR" : "TB"
|
|
17
19
|
self.graph << "\tdirection #{direction}"
|
|
20
|
+
|
|
21
|
+
# Initialize namespace grouping for clustering
|
|
22
|
+
@entities_by_namespace = Hash.new { |h, k| h[k] = [] }
|
|
23
|
+
@clustering_enabled = options[:cluster] && !er_diagram?
|
|
24
|
+
@clustered_entities_emitted = false
|
|
25
|
+
|
|
26
|
+
# Warn if clustering requested with erDiagram (not supported)
|
|
27
|
+
if options[:cluster] && er_diagram? && options[:warn]
|
|
28
|
+
warn "Clustering is not supported with erDiagram style. Use mermaid_style: classdiagram instead."
|
|
29
|
+
end
|
|
18
30
|
end
|
|
19
31
|
|
|
20
32
|
each_entity do |entity, attributes|
|
|
33
|
+
@drawn_entity_names << entity.name.to_s
|
|
21
34
|
if er_diagram?
|
|
22
35
|
# Build entity block as a single string to avoid uniq issues with closing braces
|
|
23
36
|
quoted_entity = quote_entity_name(entity)
|
|
@@ -28,6 +41,12 @@ module RailsERD
|
|
|
28
41
|
end
|
|
29
42
|
entity_lines << "\t}"
|
|
30
43
|
graph << entity_lines.join("\n")
|
|
44
|
+
elsif @clustering_enabled
|
|
45
|
+
# Collect entities by namespace for later emission
|
|
46
|
+
ns = entity.namespace || ""
|
|
47
|
+
short_name = entity.namespace ? entity.name.split("::").last : entity.name
|
|
48
|
+
entity_block = build_class_diagram_entity(short_name, entity.name, attributes)
|
|
49
|
+
@entities_by_namespace[ns] << entity_block
|
|
31
50
|
else
|
|
32
51
|
graph << "\tclass `#{entity}`"
|
|
33
52
|
attributes.each do |attr|
|
|
@@ -37,6 +56,12 @@ module RailsERD
|
|
|
37
56
|
end
|
|
38
57
|
|
|
39
58
|
each_specialization do |specialization|
|
|
59
|
+
# Emit clustered entities before the first specialization (same as relationships)
|
|
60
|
+
if @clustering_enabled && !@clustered_entities_emitted
|
|
61
|
+
emit_clustered_entities
|
|
62
|
+
@clustered_entities_emitted = true
|
|
63
|
+
end
|
|
64
|
+
|
|
40
65
|
from, to = specialization.generalized, specialization.specialized
|
|
41
66
|
if er_diagram?
|
|
42
67
|
# erDiagram doesn't have a direct polymorphic notation, use inheritance-like
|
|
@@ -48,35 +73,29 @@ module RailsERD
|
|
|
48
73
|
end
|
|
49
74
|
|
|
50
75
|
each_relationship do |relationship|
|
|
76
|
+
# Emit clustered entities before the first relationship
|
|
77
|
+
if @clustering_enabled && !@clustered_entities_emitted
|
|
78
|
+
emit_clustered_entities
|
|
79
|
+
@clustered_entities_emitted = true
|
|
80
|
+
end
|
|
81
|
+
|
|
51
82
|
from, to = relationship.source, relationship.destination
|
|
52
83
|
next unless from && to
|
|
53
84
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
from.children.each do |child|
|
|
58
|
-
graph << "\t#{quote_entity_name(child.name)} #{er_relation_notation(relationship)} #{quote_entity_name(to.name)} : \"\""
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
to.children.each do |child|
|
|
62
|
-
graph << "\t#{quote_entity_name(from.name)} #{er_relation_notation(relationship)} #{quote_entity_name(child.name)} : \"\""
|
|
63
|
-
end
|
|
64
|
-
else
|
|
65
|
-
graph << "\t`#{from.name}` #{relation_arrow(relationship)} `#{to.name}`"
|
|
66
|
-
|
|
67
|
-
from.children.each do |child|
|
|
68
|
-
graph << "\t`#{child.name}` #{relation_arrow(relationship)} `#{to.name}`"
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
to.children.each do |child|
|
|
72
|
-
graph << "\t`#{from.name}` #{relation_arrow(relationship)} `#{child.name}`"
|
|
73
|
-
end
|
|
74
|
-
end
|
|
85
|
+
draw_relation(from, to, relationship)
|
|
86
|
+
from.children.each { |child| draw_relation(child, to, relationship) }
|
|
87
|
+
to.children.each { |child| draw_relation(from, child, relationship) }
|
|
75
88
|
end
|
|
76
89
|
|
|
77
90
|
save do
|
|
78
91
|
raise "Saving diagram failed!\nOutput directory '#{File.dirname(filename)}' does not exist." unless File.directory?(File.dirname(filename))
|
|
79
92
|
|
|
93
|
+
# Emit clustered entities if not already emitted (e.g., no relationships)
|
|
94
|
+
if @clustering_enabled && !@clustered_entities_emitted
|
|
95
|
+
emit_clustered_entities
|
|
96
|
+
@clustered_entities_emitted = true
|
|
97
|
+
end
|
|
98
|
+
|
|
80
99
|
File.write(filename.gsub(/\s/,"_"), graph.uniq.join("\n"))
|
|
81
100
|
filename
|
|
82
101
|
end
|
|
@@ -93,6 +112,22 @@ module RailsERD
|
|
|
93
112
|
options[:mermaid_style] == :erdiagram || options[:mermaid_style] == :er
|
|
94
113
|
end
|
|
95
114
|
|
|
115
|
+
# Mermaid renders any entity a relationship names, so an edge to an entity that was
|
|
116
|
+
# filtered out would draw it back into the diagram. Skip those, as Graphviz already does.
|
|
117
|
+
def draw_relation(from, to, relationship)
|
|
118
|
+
return unless entity_drawn?(from.name) && entity_drawn?(to.name)
|
|
119
|
+
|
|
120
|
+
graph << if er_diagram?
|
|
121
|
+
"\t#{quote_entity_name(from.name)} #{er_relation_notation(relationship)} #{quote_entity_name(to.name)} : \"\""
|
|
122
|
+
else
|
|
123
|
+
"\t`#{from.name}` #{relation_arrow(relationship)} `#{to.name}`"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def entity_drawn?(name)
|
|
128
|
+
@drawn_entity_names.include?(name.to_s)
|
|
129
|
+
end
|
|
130
|
+
|
|
96
131
|
# Quote entity names that contain special characters (like :: for namespaces)
|
|
97
132
|
# Mermaid erDiagram requires double quotes for names with special characters
|
|
98
133
|
def quote_entity_name(name)
|
|
@@ -159,6 +194,36 @@ module RailsERD
|
|
|
159
194
|
relationship.many_to? ? "<" : ""
|
|
160
195
|
end
|
|
161
196
|
|
|
197
|
+
# Build entity lines for classDiagram mode (used in clustering)
|
|
198
|
+
def build_class_diagram_entity(short_name, full_name, attributes)
|
|
199
|
+
lines = ["\t\tclass `#{short_name}`"]
|
|
200
|
+
attributes.each do |attr|
|
|
201
|
+
lines << "\t\t`#{short_name}` : +#{attr.type} #{attr.name}"
|
|
202
|
+
end
|
|
203
|
+
lines
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Emit entities grouped by namespace for clustering
|
|
207
|
+
def emit_clustered_entities
|
|
208
|
+
# Entities without namespace go first (outside any namespace block)
|
|
209
|
+
@entities_by_namespace[""].each do |entity_lines|
|
|
210
|
+
entity_lines.each { |line| graph << line.sub(/^\t\t/, "\t") }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Then emit each namespace block
|
|
214
|
+
@entities_by_namespace.each do |ns, entity_blocks|
|
|
215
|
+
next if ns == ""
|
|
216
|
+
|
|
217
|
+
# Convert Ruby namespace (Admin::Users) to Mermaid format (Admin.Users)
|
|
218
|
+
mermaid_ns = ns.gsub("::", ".")
|
|
219
|
+
graph << "\tnamespace #{mermaid_ns} {"
|
|
220
|
+
entity_blocks.each do |entity_lines|
|
|
221
|
+
entity_lines.each { |line| graph << line }
|
|
222
|
+
end
|
|
223
|
+
graph << "\t}"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
162
227
|
end
|
|
163
228
|
end
|
|
164
229
|
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails_erd/diagram"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RailsERD
|
|
7
|
+
class Diagram
|
|
8
|
+
# Emits a JSON description of the domain in the tbls schema format
|
|
9
|
+
# (https://github.com/k1LoW/tbls). Consumable by any tbls-compatible tool —
|
|
10
|
+
# for example Liam ERD: `liam erd build --input erd.json --format tbls`.
|
|
11
|
+
#
|
|
12
|
+
# Unlike a schema.rb dump, this reflects the relationships rails-erd derives
|
|
13
|
+
# from ActiveRecord — including FKs that exist only as `belongs_to`
|
|
14
|
+
# associations and not as DB-level constraints.
|
|
15
|
+
class Tbls < Diagram
|
|
16
|
+
attr_accessor :tables_by_name
|
|
17
|
+
|
|
18
|
+
setup do
|
|
19
|
+
self.tables_by_name = {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
each_entity do |entity, _attributes|
|
|
23
|
+
next if entity.generalized?
|
|
24
|
+
next unless entity.model
|
|
25
|
+
|
|
26
|
+
table_name = entity.model.table_name
|
|
27
|
+
next if tables_by_name.key?(table_name)
|
|
28
|
+
|
|
29
|
+
tables_by_name[table_name] = build_table(entity)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
each_relationship do |relationship|
|
|
33
|
+
next if relationship.indirect?
|
|
34
|
+
next unless relationship.source && relationship.destination
|
|
35
|
+
next if relationship.source.generalized? || relationship.destination.generalized?
|
|
36
|
+
|
|
37
|
+
add_foreign_keys(relationship)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
save do
|
|
41
|
+
dir = File.dirname(filename)
|
|
42
|
+
raise "Saving diagram failed!\nOutput directory '#{dir}' does not exist." unless File.directory?(dir)
|
|
43
|
+
|
|
44
|
+
File.write(filename, JSON.pretty_generate(schema_payload))
|
|
45
|
+
filename
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def filename
|
|
49
|
+
"#{options.filename}.json"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def schema_payload
|
|
55
|
+
{
|
|
56
|
+
name: domain.name || "rails-erd",
|
|
57
|
+
tables: tables_by_name.values,
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_table(entity)
|
|
62
|
+
model = entity.model
|
|
63
|
+
comment = model.respond_to?(:table_comment) ? model.table_comment : nil
|
|
64
|
+
payload = {
|
|
65
|
+
name: model.table_name,
|
|
66
|
+
type: "BASE TABLE",
|
|
67
|
+
columns: entity.attributes.map { |attr| column_payload(attr) },
|
|
68
|
+
indexes: indexes_payload(model),
|
|
69
|
+
constraints: primary_key_constraints(model),
|
|
70
|
+
}
|
|
71
|
+
payload[:comment] = comment if comment && !comment.empty?
|
|
72
|
+
payload
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def column_payload(attr)
|
|
76
|
+
column = attr.column
|
|
77
|
+
payload = {
|
|
78
|
+
name: column.name,
|
|
79
|
+
type: column.sql_type.to_s,
|
|
80
|
+
nullable: column.null,
|
|
81
|
+
}
|
|
82
|
+
payload[:default] = column.default.to_s unless column.default.nil?
|
|
83
|
+
comment = column.comment if column.respond_to?(:comment)
|
|
84
|
+
payload[:comment] = comment if comment && !comment.empty?
|
|
85
|
+
payload
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def indexes_payload(model)
|
|
89
|
+
return [] unless model.connection.respond_to?(:indexes)
|
|
90
|
+
|
|
91
|
+
model.connection.indexes(model.table_name).map do |idx|
|
|
92
|
+
columns = Array(idx.columns).map(&:to_s)
|
|
93
|
+
{
|
|
94
|
+
name: idx.name,
|
|
95
|
+
def: index_def(model.table_name, idx, columns),
|
|
96
|
+
table: model.table_name,
|
|
97
|
+
columns: columns,
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
rescue StandardError
|
|
101
|
+
[]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def index_def(table_name, idx, columns)
|
|
105
|
+
unique = idx.unique ? "UNIQUE " : ""
|
|
106
|
+
"CREATE #{unique}INDEX #{idx.name} ON #{table_name} (#{columns.join(", ")})"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def primary_key_constraints(model)
|
|
110
|
+
pk = Array(model.primary_key).compact.map(&:to_s)
|
|
111
|
+
return [] if pk.empty?
|
|
112
|
+
|
|
113
|
+
[{
|
|
114
|
+
name: "#{model.table_name}_pkey",
|
|
115
|
+
type: "PRIMARY KEY",
|
|
116
|
+
def: "PRIMARY KEY (#{pk.join(", ")})",
|
|
117
|
+
table: model.table_name,
|
|
118
|
+
referenced_table: "",
|
|
119
|
+
columns: pk,
|
|
120
|
+
}]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def add_foreign_keys(relationship)
|
|
124
|
+
relationship.associations.each do |assoc|
|
|
125
|
+
next if assoc.options[:polymorphic]
|
|
126
|
+
next unless assoc.belongs_to?
|
|
127
|
+
|
|
128
|
+
fk_columns = Array(assoc.send(Domain.foreign_key_method_name)).map(&:to_s).reject(&:empty?)
|
|
129
|
+
next if fk_columns.empty?
|
|
130
|
+
|
|
131
|
+
target_model = safe_klass(assoc)
|
|
132
|
+
next unless target_model
|
|
133
|
+
|
|
134
|
+
target_pk = Array(target_model.primary_key).compact.map(&:to_s)
|
|
135
|
+
next if target_pk.empty?
|
|
136
|
+
|
|
137
|
+
owning_table = assoc.active_record.table_name
|
|
138
|
+
target_table = target_model.table_name
|
|
139
|
+
name = "fk_#{owning_table}_#{fk_columns.join("_")}"
|
|
140
|
+
|
|
141
|
+
add_constraint(owning_table, {
|
|
142
|
+
name: name,
|
|
143
|
+
type: "FOREIGN KEY",
|
|
144
|
+
def: "FOREIGN KEY (#{fk_columns.join(", ")}) REFERENCES #{target_table}(#{target_pk.join(", ")})",
|
|
145
|
+
table: owning_table,
|
|
146
|
+
referenced_table: target_table,
|
|
147
|
+
columns: fk_columns,
|
|
148
|
+
referenced_columns: target_pk,
|
|
149
|
+
})
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def safe_klass(association)
|
|
154
|
+
association.klass
|
|
155
|
+
rescue NameError
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def add_constraint(table_name, constraint)
|
|
160
|
+
table = tables_by_name[table_name]
|
|
161
|
+
return unless table
|
|
162
|
+
return if table[:constraints].any? { |c| c[:name] == constraint[:name] }
|
|
163
|
+
|
|
164
|
+
table[:constraints] << constraint
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
data/lib/rails_erd/diagram.rb
CHANGED
|
@@ -55,6 +55,14 @@ module RailsERD
|
|
|
55
55
|
# attributes:: Selects which attributes to display. Can be any combination of
|
|
56
56
|
# +:content+, +:primary_keys+, +:foreign_keys+, +:timestamps+, or
|
|
57
57
|
# +:inheritance+.
|
|
58
|
+
# only_attributes:: Shows only the listed attributes for the given models,
|
|
59
|
+
# without affecting other models. Accepts a hash that maps
|
|
60
|
+
# model names to a list of attribute names to keep. From the
|
|
61
|
+
# command line it can also be given as a comma separated
|
|
62
|
+
# string of +Model.attribute+ entries, for example
|
|
63
|
+
# <tt>only_attributes="Book.title,Book.isbn"</tt>. Models
|
|
64
|
+
# that are not listed keep all of their attributes. Unlike
|
|
65
|
+
# +exclude_attributes+ it cannot be set to +true+.
|
|
58
66
|
# exclude_attributes:: Hides attributes on a per-model basis, without affecting
|
|
59
67
|
# other models. Accepts a hash that maps model names to
|
|
60
68
|
# either +true+ (hide all attributes for that model) or a
|
|
@@ -63,6 +71,7 @@ module RailsERD
|
|
|
63
71
|
# entry is either +Model+ (hide all attributes) or
|
|
64
72
|
# +Model.attribute+ (hide a single attribute), for example
|
|
65
73
|
# <tt>exclude_attributes="BigTable,User.password_digest"</tt>.
|
|
74
|
+
# Applied after +only_attributes+.
|
|
66
75
|
# disconnected:: Set to +false+ to exclude entities that are not connected to other
|
|
67
76
|
# entities. Defaults to +false+.
|
|
68
77
|
# indirect:: Set to +false+ to exclude relationships that are indirect.
|
|
@@ -84,21 +93,26 @@ module RailsERD
|
|
|
84
93
|
new(Domain.generate(options), options).create
|
|
85
94
|
end
|
|
86
95
|
|
|
87
|
-
# Canonicalises
|
|
88
|
-
# model names (as strings) to
|
|
89
|
-
#
|
|
96
|
+
# Canonicalises an attribute filter option (+only_attributes+ or
|
|
97
|
+
# +exclude_attributes+) into a hash that maps model names (as strings) to
|
|
98
|
+
# either +true+ (the whole model was named, without any attribute) or an
|
|
99
|
+
# array of attribute names. Accepts several input shapes:
|
|
90
100
|
#
|
|
91
101
|
# * a hash, e.g. <tt>{ "BigTable" => true, "User" => ["password_digest"] }</tt>
|
|
92
|
-
# (values may be +true+/+"all"+ to
|
|
93
|
-
# to
|
|
102
|
+
# (values may be +true+/+"all"+ to name the whole model, +false+/+nil+
|
|
103
|
+
# to name no attributes, or a comma separated string / array of names);
|
|
94
104
|
# * a string or array of <tt>Model</tt> / <tt>Model.attribute</tt>
|
|
95
105
|
# entries, e.g. <tt>"BigTable,User.password_digest"</tt>.
|
|
96
|
-
|
|
106
|
+
#
|
|
107
|
+
# How +true+ and an empty list are interpreted is up to the caller: for
|
|
108
|
+
# +exclude_attributes+ they mean "hide every attribute" and "hide none",
|
|
109
|
+
# for +only_attributes+ they both mean "keep every attribute".
|
|
110
|
+
def normalize_attribute_filter(value)
|
|
97
111
|
return {} if value.nil? || value == false
|
|
98
112
|
|
|
99
113
|
if value.is_a?(Hash)
|
|
100
114
|
value.each_with_object({}) do |(model, attrs), result|
|
|
101
|
-
result[model.to_s] =
|
|
115
|
+
result[model.to_s] = normalize_attribute_filter_value(attrs)
|
|
102
116
|
end
|
|
103
117
|
else
|
|
104
118
|
entries = Array(value).flat_map { |entry| entry.to_s.split(",") }
|
|
@@ -116,6 +130,25 @@ module RailsERD
|
|
|
116
130
|
end
|
|
117
131
|
end
|
|
118
132
|
|
|
133
|
+
# Canonicalises the +only_attributes+ option. See #normalize_attribute_filter.
|
|
134
|
+
# Unlike +exclude_attributes+ it cannot be enabled with a bare +true+:
|
|
135
|
+
# "show only all attributes" has no meaning, so it is rejected rather than
|
|
136
|
+
# silently ignored.
|
|
137
|
+
def normalize_only_attributes(value)
|
|
138
|
+
if value == true
|
|
139
|
+
raise ArgumentError, "only_attributes cannot be true: it takes the attributes to keep, " \
|
|
140
|
+
"either as a hash of model names to attribute names, or as a list of Model.attribute " \
|
|
141
|
+
"entries. Use attributes: false or exclude_attributes to hide attributes."
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
normalize_attribute_filter(value)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Canonicalises the +exclude_attributes+ option. See #normalize_attribute_filter.
|
|
148
|
+
def normalize_exclude_attributes(value)
|
|
149
|
+
normalize_attribute_filter(value)
|
|
150
|
+
end
|
|
151
|
+
|
|
119
152
|
protected
|
|
120
153
|
|
|
121
154
|
def setup(&block)
|
|
@@ -144,7 +177,7 @@ module RailsERD
|
|
|
144
177
|
@callbacks ||= Hash.new { proc {} }
|
|
145
178
|
end
|
|
146
179
|
|
|
147
|
-
def
|
|
180
|
+
def normalize_attribute_filter_value(attrs)
|
|
148
181
|
case attrs
|
|
149
182
|
when true then true
|
|
150
183
|
when false, nil then []
|
|
@@ -231,8 +264,7 @@ module RailsERD
|
|
|
231
264
|
|
|
232
265
|
def filtered_entities
|
|
233
266
|
@domain.entities.reject { |entity|
|
|
234
|
-
|
|
235
|
-
options[:only].present? && entity.model && ![options[:only]].flatten.map(&:to_sym).include?(entity.name.to_sym) or
|
|
267
|
+
excluded_by_filter?(entity) or
|
|
236
268
|
!options.inheritance && entity.specialized? or
|
|
237
269
|
!options.polymorphism && entity.generalized? or
|
|
238
270
|
!options.disconnected && entity.disconnected?
|
|
@@ -243,14 +275,76 @@ module RailsERD
|
|
|
243
275
|
|
|
244
276
|
def filtered_relationships
|
|
245
277
|
@domain.relationships.reject { |relationship|
|
|
246
|
-
!options.indirect && relationship.indirect?
|
|
278
|
+
(!options.indirect && relationship.indirect?) ||
|
|
279
|
+
(!options.recursive && relationship.recursive?) ||
|
|
280
|
+
# Drop relationships to a model removed by :only/:exclude, otherwise the filtered-out
|
|
281
|
+
# model leaks back in: Graphviz skips such edges implicitly (it only draws an edge when
|
|
282
|
+
# both nodes exist) but Mermaid emits every relationship and renders any named entity.
|
|
283
|
+
excluded_by_filter?(relationship.source) ||
|
|
284
|
+
excluded_by_filter?(relationship.destination)
|
|
247
285
|
}
|
|
248
286
|
end
|
|
249
287
|
|
|
288
|
+
# Whether the entity was removed from the diagram by the :only or :exclude option.
|
|
289
|
+
# Used to filter both entities and the relationships that touch them.
|
|
290
|
+
#
|
|
291
|
+
def excluded_by_filter?(entity)
|
|
292
|
+
name = entity.name.to_s
|
|
293
|
+
|
|
294
|
+
if options.exclude.present?
|
|
295
|
+
patterns = [options.exclude].flatten
|
|
296
|
+
return true if patterns.any? { |pattern| matches_pattern?(pattern, name) }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
if options[:only].present? && entity.model
|
|
300
|
+
patterns = [options[:only]].flatten
|
|
301
|
+
return true unless patterns.any? { |pattern| matches_pattern?(pattern, name) }
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
false
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Matches a name against a pattern. Supports three pattern types:
|
|
308
|
+
#
|
|
309
|
+
# - Exact match: "Foo" matches only "Foo"
|
|
310
|
+
# - Glob pattern: "SolidQueue::*" matches "SolidQueue::Job", etc.
|
|
311
|
+
# - Regex pattern: "/^Active/" matches "ActiveRecord", "ActiveStorage::Blob"
|
|
312
|
+
#
|
|
313
|
+
def matches_pattern?(pattern, name)
|
|
314
|
+
pattern_str = pattern.to_s
|
|
315
|
+
|
|
316
|
+
# Regex pattern: /pattern/ or /pattern/flags
|
|
317
|
+
#
|
|
318
|
+
if pattern_str.start_with?("/") && pattern_str =~ %r{\A/(.+)/([imx]*)\z}
|
|
319
|
+
regex_body = Regexp.last_match(1)
|
|
320
|
+
flags_str = Regexp.last_match(2)
|
|
321
|
+
flags = 0
|
|
322
|
+
flags |= Regexp::IGNORECASE if flags_str.include?("i")
|
|
323
|
+
flags |= Regexp::MULTILINE if flags_str.include?("m")
|
|
324
|
+
flags |= Regexp::EXTENDED if flags_str.include?("x")
|
|
325
|
+
return Regexp.new(regex_body, flags).match?(name.to_s)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Glob pattern: contains *, ?, or [
|
|
329
|
+
#
|
|
330
|
+
if pattern_str.include?("*") || pattern_str.include?("?") || pattern_str.include?("[")
|
|
331
|
+
return File.fnmatch?(pattern_str, name.to_s)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Exact match (backward compatible)
|
|
335
|
+
pattern_str == name.to_s
|
|
336
|
+
end
|
|
337
|
+
|
|
250
338
|
def filtered_specializations
|
|
251
339
|
@domain.specializations.reject { |specialization|
|
|
252
340
|
!options.inheritance && specialization.inheritance? or
|
|
253
|
-
!options.polymorphism && specialization.polymorphic?
|
|
341
|
+
!options.polymorphism && specialization.polymorphic? or
|
|
342
|
+
# Drop specializations whose generalized or specialized entity is not
|
|
343
|
+
# part of the rendered domain (e.g. an abstract parent whose child model
|
|
344
|
+
# has no table). These resolve to a Null entity with a blank name and
|
|
345
|
+
# would otherwise produce an edge to a nameless entity (invalid output).
|
|
346
|
+
specialization.generalized.name.to_s.empty? or
|
|
347
|
+
specialization.specialized.name.to_s.empty?
|
|
254
348
|
}
|
|
255
349
|
end
|
|
256
350
|
|
|
@@ -258,15 +352,34 @@ module RailsERD
|
|
|
258
352
|
excluded = excluded_attributes_for(entity)
|
|
259
353
|
return [] if excluded == :all
|
|
260
354
|
|
|
261
|
-
|
|
355
|
+
only = only_attributes_for(entity)
|
|
356
|
+
|
|
357
|
+
entity.attributes.select { |attribute|
|
|
358
|
+
# Keep only the attributes listed for this specific model, mirroring how
|
|
359
|
+
# :only is applied before :exclude when filtering entities.
|
|
360
|
+
next false if only != :all && !only.include?(attribute.name)
|
|
262
361
|
# Hide attributes excluded for this specific model.
|
|
263
|
-
excluded.include?(attribute.name)
|
|
264
|
-
#
|
|
265
|
-
|
|
266
|
-
|
|
362
|
+
next false if excluded.include?(attribute.name)
|
|
363
|
+
# Hide every attribute when the :attributes option is off or the entity
|
|
364
|
+
# is specialized (its attributes are shown on the parent instead).
|
|
365
|
+
next false if !options.attributes || entity.specialized?
|
|
366
|
+
# Otherwise keep only attributes matching the requested :attributes types.
|
|
367
|
+
[*options.attributes].any? { |type| attribute.send(:"#{type.to_s.chomp('s')}?") }
|
|
267
368
|
}
|
|
268
369
|
end
|
|
269
370
|
|
|
371
|
+
# Returns the attributes the given entity is restricted to through the
|
|
372
|
+
# +only_attributes+ option. Returns +:all+ when no restriction applies,
|
|
373
|
+
# which is the case when the model is not listed at all, or is listed
|
|
374
|
+
# without naming any attribute.
|
|
375
|
+
def only_attributes_for(entity)
|
|
376
|
+
spec = normalized_only_attributes[entity.name]
|
|
377
|
+
return :all if spec.nil? || spec == true
|
|
378
|
+
|
|
379
|
+
names = Array(spec)
|
|
380
|
+
names.empty? ? :all : names
|
|
381
|
+
end
|
|
382
|
+
|
|
270
383
|
# Returns the attribute exclusions configured for the given entity through
|
|
271
384
|
# the +exclude_attributes+ option. Returns +:all+ when every attribute
|
|
272
385
|
# should be hidden, or an array of attribute names otherwise.
|
|
@@ -276,6 +389,10 @@ module RailsERD
|
|
|
276
389
|
Array(spec)
|
|
277
390
|
end
|
|
278
391
|
|
|
392
|
+
def normalized_only_attributes
|
|
393
|
+
@normalized_only_attributes ||= self.class.normalize_only_attributes(options.only_attributes)
|
|
394
|
+
end
|
|
395
|
+
|
|
279
396
|
def normalized_exclude_attributes
|
|
280
397
|
@normalized_exclude_attributes ||= self.class.normalize_exclude_attributes(options.exclude_attributes)
|
|
281
398
|
end
|