rails-erd 2.0.1 → 2.1.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 +335 -0
- data/LICENSE.md +19 -0
- data/README.md +220 -5
- data/Rakefile +1 -1
- data/lib/rails_erd/cli.rb +16 -9
- data/lib/rails_erd/config.rb +5 -0
- data/lib/rails_erd/diagram/mermaid.rb +64 -0
- data/lib/rails_erd/diagram/tbls.rb +168 -0
- data/lib/rails_erd/diagram.rb +138 -8
- data/lib/rails_erd/domain.rb +42 -6
- data/lib/rails_erd/version.rb +1 -1
- data/lib/rails_erd.rb +1 -0
- data/test/unit/config_test.rb +11 -0
- data/test/unit/diagram_test.rb +139 -1
- data/test/unit/domain_test.rb +117 -0
- data/test/unit/mermaid_test.rb +124 -2
- data/test/unit/tbls_test.rb +152 -0
- metadata +6 -1
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
|
|
@@ -72,6 +72,11 @@ Choice.options do
|
|
|
72
72
|
desc "Filter to exclude listed models in diagram."
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
option :exclude_attributes do
|
|
76
|
+
long "--exclude_attributes=MODEL[.ATTRIBUTE],..."
|
|
77
|
+
desc "Hide attributes per model. Use Model to hide all its attributes or Model.attribute to hide one, e.g. BigTable,User.password_digest."
|
|
78
|
+
end
|
|
79
|
+
|
|
75
80
|
option :sort do
|
|
76
81
|
long "--sort=BOOLEAN"
|
|
77
82
|
desc "Sort attribute list alphabetically"
|
|
@@ -188,10 +193,11 @@ module RailsERD
|
|
|
188
193
|
def initialize(path, options)
|
|
189
194
|
@path, @options = path, options
|
|
190
195
|
generator = options[:generator] || RailsERD.options[:generator]
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
196
|
+
case generator
|
|
197
|
+
when :mermaid then require "rails_erd/diagram/mermaid"
|
|
198
|
+
when :tbls then require "rails_erd/diagram/tbls"
|
|
199
|
+
when :graphviz then require "rails_erd/diagram/graphviz"
|
|
200
|
+
else require "rails_erd/diagram/mermaid"
|
|
195
201
|
end
|
|
196
202
|
end
|
|
197
203
|
|
|
@@ -231,10 +237,11 @@ module RailsERD
|
|
|
231
237
|
|
|
232
238
|
def generator
|
|
233
239
|
generator_type = options[:generator] || RailsERD.options[:generator]
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
240
|
+
case generator_type
|
|
241
|
+
when :mermaid then RailsERD::Diagram::Mermaid
|
|
242
|
+
when :tbls then RailsERD::Diagram::Tbls
|
|
243
|
+
when :graphviz then RailsERD::Diagram::Graphviz
|
|
244
|
+
else RailsERD::Diagram::Mermaid
|
|
238
245
|
end
|
|
239
246
|
end
|
|
240
247
|
|
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> => true | [<string>] }
|
|
77
|
+
when :exclude_attributes
|
|
78
|
+
require "rails_erd/diagram"
|
|
79
|
+
RailsERD::Diagram.normalize_exclude_attributes(value)
|
|
80
|
+
|
|
76
81
|
# true | false
|
|
77
82
|
when :disconnected, :indirect, :inheritance, :markup, :polymorphism,
|
|
78
83
|
:warn, :cluster
|
|
@@ -15,6 +15,16 @@ module RailsERD
|
|
|
15
15
|
# Respect orientation option: horizontal = TB (top-down), vertical = LR (left-right)
|
|
16
16
|
direction = (options.orientation.to_s == "vertical") ? "LR" : "TB"
|
|
17
17
|
self.graph << "\tdirection #{direction}"
|
|
18
|
+
|
|
19
|
+
# Initialize namespace grouping for clustering
|
|
20
|
+
@entities_by_namespace = Hash.new { |h, k| h[k] = [] }
|
|
21
|
+
@clustering_enabled = options[:cluster] && !er_diagram?
|
|
22
|
+
@clustered_entities_emitted = false
|
|
23
|
+
|
|
24
|
+
# Warn if clustering requested with erDiagram (not supported)
|
|
25
|
+
if options[:cluster] && er_diagram? && options[:warn]
|
|
26
|
+
warn "Clustering is not supported with erDiagram style. Use mermaid_style: classdiagram instead."
|
|
27
|
+
end
|
|
18
28
|
end
|
|
19
29
|
|
|
20
30
|
each_entity do |entity, attributes|
|
|
@@ -28,6 +38,12 @@ module RailsERD
|
|
|
28
38
|
end
|
|
29
39
|
entity_lines << "\t}"
|
|
30
40
|
graph << entity_lines.join("\n")
|
|
41
|
+
elsif @clustering_enabled
|
|
42
|
+
# Collect entities by namespace for later emission
|
|
43
|
+
ns = entity.namespace || ""
|
|
44
|
+
short_name = entity.namespace ? entity.name.split("::").last : entity.name
|
|
45
|
+
entity_block = build_class_diagram_entity(short_name, entity.name, attributes)
|
|
46
|
+
@entities_by_namespace[ns] << entity_block
|
|
31
47
|
else
|
|
32
48
|
graph << "\tclass `#{entity}`"
|
|
33
49
|
attributes.each do |attr|
|
|
@@ -37,6 +53,12 @@ module RailsERD
|
|
|
37
53
|
end
|
|
38
54
|
|
|
39
55
|
each_specialization do |specialization|
|
|
56
|
+
# Emit clustered entities before the first specialization (same as relationships)
|
|
57
|
+
if @clustering_enabled && !@clustered_entities_emitted
|
|
58
|
+
emit_clustered_entities
|
|
59
|
+
@clustered_entities_emitted = true
|
|
60
|
+
end
|
|
61
|
+
|
|
40
62
|
from, to = specialization.generalized, specialization.specialized
|
|
41
63
|
if er_diagram?
|
|
42
64
|
# erDiagram doesn't have a direct polymorphic notation, use inheritance-like
|
|
@@ -48,6 +70,12 @@ module RailsERD
|
|
|
48
70
|
end
|
|
49
71
|
|
|
50
72
|
each_relationship do |relationship|
|
|
73
|
+
# Emit clustered entities before the first relationship
|
|
74
|
+
if @clustering_enabled && !@clustered_entities_emitted
|
|
75
|
+
emit_clustered_entities
|
|
76
|
+
@clustered_entities_emitted = true
|
|
77
|
+
end
|
|
78
|
+
|
|
51
79
|
from, to = relationship.source, relationship.destination
|
|
52
80
|
next unless from && to
|
|
53
81
|
|
|
@@ -77,6 +105,12 @@ module RailsERD
|
|
|
77
105
|
save do
|
|
78
106
|
raise "Saving diagram failed!\nOutput directory '#{File.dirname(filename)}' does not exist." unless File.directory?(File.dirname(filename))
|
|
79
107
|
|
|
108
|
+
# Emit clustered entities if not already emitted (e.g., no relationships)
|
|
109
|
+
if @clustering_enabled && !@clustered_entities_emitted
|
|
110
|
+
emit_clustered_entities
|
|
111
|
+
@clustered_entities_emitted = true
|
|
112
|
+
end
|
|
113
|
+
|
|
80
114
|
File.write(filename.gsub(/\s/,"_"), graph.uniq.join("\n"))
|
|
81
115
|
filename
|
|
82
116
|
end
|
|
@@ -159,6 +193,36 @@ module RailsERD
|
|
|
159
193
|
relationship.many_to? ? "<" : ""
|
|
160
194
|
end
|
|
161
195
|
|
|
196
|
+
# Build entity lines for classDiagram mode (used in clustering)
|
|
197
|
+
def build_class_diagram_entity(short_name, full_name, attributes)
|
|
198
|
+
lines = ["\t\tclass `#{short_name}`"]
|
|
199
|
+
attributes.each do |attr|
|
|
200
|
+
lines << "\t\t`#{short_name}` : +#{attr.type} #{attr.name}"
|
|
201
|
+
end
|
|
202
|
+
lines
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Emit entities grouped by namespace for clustering
|
|
206
|
+
def emit_clustered_entities
|
|
207
|
+
# Entities without namespace go first (outside any namespace block)
|
|
208
|
+
@entities_by_namespace[""].each do |entity_lines|
|
|
209
|
+
entity_lines.each { |line| graph << line.sub(/^\t\t/, "\t") }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Then emit each namespace block
|
|
213
|
+
@entities_by_namespace.each do |ns, entity_blocks|
|
|
214
|
+
next if ns == ""
|
|
215
|
+
|
|
216
|
+
# Convert Ruby namespace (Admin::Users) to Mermaid format (Admin.Users)
|
|
217
|
+
mermaid_ns = ns.gsub("::", ".")
|
|
218
|
+
graph << "\tnamespace #{mermaid_ns} {"
|
|
219
|
+
entity_blocks.each do |entity_lines|
|
|
220
|
+
entity_lines.each { |line| graph << line }
|
|
221
|
+
end
|
|
222
|
+
graph << "\t}"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
162
226
|
end
|
|
163
227
|
end
|
|
164
228
|
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
|
+
# exclude_attributes:: Hides attributes on a per-model basis, without affecting
|
|
59
|
+
# other models. Accepts a hash that maps model names to
|
|
60
|
+
# either +true+ (hide all attributes for that model) or a
|
|
61
|
+
# list of attribute names to hide. From the command line it
|
|
62
|
+
# can also be given as a comma separated string where each
|
|
63
|
+
# entry is either +Model+ (hide all attributes) or
|
|
64
|
+
# +Model.attribute+ (hide a single attribute), for example
|
|
65
|
+
# <tt>exclude_attributes="BigTable,User.password_digest"</tt>.
|
|
58
66
|
# disconnected:: Set to +false+ to exclude entities that are not connected to other
|
|
59
67
|
# entities. Defaults to +false+.
|
|
60
68
|
# indirect:: Set to +false+ to exclude relationships that are indirect.
|
|
@@ -76,6 +84,38 @@ module RailsERD
|
|
|
76
84
|
new(Domain.generate(options), options).create
|
|
77
85
|
end
|
|
78
86
|
|
|
87
|
+
# Canonicalises the +exclude_attributes+ option into a hash that maps
|
|
88
|
+
# model names (as strings) to either +true+ (hide all attributes) or an
|
|
89
|
+
# array of attribute names to hide. Accepts several input shapes:
|
|
90
|
+
#
|
|
91
|
+
# * a hash, e.g. <tt>{ "BigTable" => true, "User" => ["password_digest"] }</tt>
|
|
92
|
+
# (values may be +true+/+"all"+ to hide every attribute, +false+/+nil+
|
|
93
|
+
# to hide none, or a comma separated string / array of names);
|
|
94
|
+
# * a string or array of <tt>Model</tt> / <tt>Model.attribute</tt>
|
|
95
|
+
# entries, e.g. <tt>"BigTable,User.password_digest"</tt>.
|
|
96
|
+
def normalize_exclude_attributes(value)
|
|
97
|
+
return {} if value.nil? || value == false
|
|
98
|
+
|
|
99
|
+
if value.is_a?(Hash)
|
|
100
|
+
value.each_with_object({}) do |(model, attrs), result|
|
|
101
|
+
result[model.to_s] = normalize_exclude_attribute_value(attrs)
|
|
102
|
+
end
|
|
103
|
+
else
|
|
104
|
+
entries = Array(value).flat_map { |entry| entry.to_s.split(",") }
|
|
105
|
+
entries.each_with_object({}) do |entry, result|
|
|
106
|
+
model, attribute = entry.split(".", 2)
|
|
107
|
+
model = model.to_s.strip
|
|
108
|
+
next if model.empty?
|
|
109
|
+
|
|
110
|
+
if attribute.nil?
|
|
111
|
+
result[model] = true
|
|
112
|
+
elsif result[model] != true
|
|
113
|
+
(result[model] ||= []) << attribute.strip
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
79
119
|
protected
|
|
80
120
|
|
|
81
121
|
def setup(&block)
|
|
@@ -103,6 +143,16 @@ module RailsERD
|
|
|
103
143
|
def callbacks
|
|
104
144
|
@callbacks ||= Hash.new { proc {} }
|
|
105
145
|
end
|
|
146
|
+
|
|
147
|
+
def normalize_exclude_attribute_value(attrs)
|
|
148
|
+
case attrs
|
|
149
|
+
when true then true
|
|
150
|
+
when false, nil then []
|
|
151
|
+
when "all", :all, "true" then true
|
|
152
|
+
else
|
|
153
|
+
Array(attrs).flat_map { |attr| attr.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
106
156
|
end
|
|
107
157
|
|
|
108
158
|
# The options that are used to create this diagram.
|
|
@@ -181,8 +231,7 @@ module RailsERD
|
|
|
181
231
|
|
|
182
232
|
def filtered_entities
|
|
183
233
|
@domain.entities.reject { |entity|
|
|
184
|
-
|
|
185
|
-
options[:only].present? && entity.model && ![options[:only]].flatten.map(&:to_sym).include?(entity.name.to_sym) or
|
|
234
|
+
excluded_by_filter?(entity) or
|
|
186
235
|
!options.inheritance && entity.specialized? or
|
|
187
236
|
!options.polymorphism && entity.generalized? or
|
|
188
237
|
!options.disconnected && entity.disconnected?
|
|
@@ -193,25 +242,106 @@ module RailsERD
|
|
|
193
242
|
|
|
194
243
|
def filtered_relationships
|
|
195
244
|
@domain.relationships.reject { |relationship|
|
|
196
|
-
!options.indirect && relationship.indirect?
|
|
245
|
+
(!options.indirect && relationship.indirect?) ||
|
|
246
|
+
# Drop relationships to a model removed by :only/:exclude, otherwise the filtered-out
|
|
247
|
+
# model leaks back in: Graphviz skips such edges implicitly (it only draws an edge when
|
|
248
|
+
# both nodes exist) but Mermaid emits every relationship and renders any named entity.
|
|
249
|
+
excluded_by_filter?(relationship.source) ||
|
|
250
|
+
excluded_by_filter?(relationship.destination)
|
|
197
251
|
}
|
|
198
252
|
end
|
|
199
253
|
|
|
254
|
+
# Whether the entity was removed from the diagram by the :only or :exclude option.
|
|
255
|
+
# Used to filter both entities and the relationships that touch them.
|
|
256
|
+
#
|
|
257
|
+
def excluded_by_filter?(entity)
|
|
258
|
+
name = entity.name.to_s
|
|
259
|
+
|
|
260
|
+
if options.exclude.present?
|
|
261
|
+
patterns = [options.exclude].flatten
|
|
262
|
+
return true if patterns.any? { |pattern| matches_pattern?(pattern, name) }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
if options[:only].present? && entity.model
|
|
266
|
+
patterns = [options[:only]].flatten
|
|
267
|
+
return true unless patterns.any? { |pattern| matches_pattern?(pattern, name) }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
false
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Matches a name against a pattern. Supports three pattern types:
|
|
274
|
+
#
|
|
275
|
+
# - Exact match: "Foo" matches only "Foo"
|
|
276
|
+
# - Glob pattern: "SolidQueue::*" matches "SolidQueue::Job", etc.
|
|
277
|
+
# - Regex pattern: "/^Active/" matches "ActiveRecord", "ActiveStorage::Blob"
|
|
278
|
+
#
|
|
279
|
+
def matches_pattern?(pattern, name)
|
|
280
|
+
pattern_str = pattern.to_s
|
|
281
|
+
|
|
282
|
+
# Regex pattern: /pattern/ or /pattern/flags
|
|
283
|
+
#
|
|
284
|
+
if pattern_str.start_with?("/") && pattern_str =~ %r{\A/(.+)/([imx]*)\z}
|
|
285
|
+
regex_body = Regexp.last_match(1)
|
|
286
|
+
flags_str = Regexp.last_match(2)
|
|
287
|
+
flags = 0
|
|
288
|
+
flags |= Regexp::IGNORECASE if flags_str.include?("i")
|
|
289
|
+
flags |= Regexp::MULTILINE if flags_str.include?("m")
|
|
290
|
+
flags |= Regexp::EXTENDED if flags_str.include?("x")
|
|
291
|
+
return Regexp.new(regex_body, flags).match?(name.to_s)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Glob pattern: contains *, ?, or [
|
|
295
|
+
#
|
|
296
|
+
if pattern_str.include?("*") || pattern_str.include?("?") || pattern_str.include?("[")
|
|
297
|
+
return File.fnmatch?(pattern_str, name.to_s)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Exact match (backward compatible)
|
|
301
|
+
pattern_str == name.to_s
|
|
302
|
+
end
|
|
303
|
+
|
|
200
304
|
def filtered_specializations
|
|
201
305
|
@domain.specializations.reject { |specialization|
|
|
202
306
|
!options.inheritance && specialization.inheritance? or
|
|
203
|
-
!options.polymorphism && specialization.polymorphic?
|
|
307
|
+
!options.polymorphism && specialization.polymorphic? or
|
|
308
|
+
# Drop specializations whose generalized or specialized entity is not
|
|
309
|
+
# part of the rendered domain (e.g. an abstract parent whose child model
|
|
310
|
+
# has no table). These resolve to a Null entity with a blank name and
|
|
311
|
+
# would otherwise produce an edge to a nameless entity (invalid output).
|
|
312
|
+
specialization.generalized.name.to_s.empty? or
|
|
313
|
+
specialization.specialized.name.to_s.empty?
|
|
204
314
|
}
|
|
205
315
|
end
|
|
206
316
|
|
|
207
317
|
def filtered_attributes(entity)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
318
|
+
excluded = excluded_attributes_for(entity)
|
|
319
|
+
return [] if excluded == :all
|
|
320
|
+
|
|
321
|
+
entity.attributes.select { |attribute|
|
|
322
|
+
# Hide attributes excluded for this specific model.
|
|
323
|
+
next false if excluded.include?(attribute.name)
|
|
324
|
+
# Hide every attribute when the :attributes option is off or the entity
|
|
325
|
+
# is specialized (its attributes are shown on the parent instead).
|
|
326
|
+
next false if !options.attributes || entity.specialized?
|
|
327
|
+
# Otherwise keep only attributes matching the requested :attributes types.
|
|
328
|
+
[*options.attributes].any? { |type| attribute.send(:"#{type.to_s.chomp('s')}?") }
|
|
212
329
|
}
|
|
213
330
|
end
|
|
214
331
|
|
|
332
|
+
# Returns the attribute exclusions configured for the given entity through
|
|
333
|
+
# the +exclude_attributes+ option. Returns +:all+ when every attribute
|
|
334
|
+
# should be hidden, or an array of attribute names otherwise.
|
|
335
|
+
def excluded_attributes_for(entity)
|
|
336
|
+
spec = normalized_exclude_attributes[entity.name]
|
|
337
|
+
return :all if spec == true
|
|
338
|
+
Array(spec)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def normalized_exclude_attributes
|
|
342
|
+
@normalized_exclude_attributes ||= self.class.normalize_exclude_attributes(options.exclude_attributes)
|
|
343
|
+
end
|
|
344
|
+
|
|
215
345
|
def warn(message)
|
|
216
346
|
puts "Warning: #{message}" if options.warn
|
|
217
347
|
end
|
data/lib/rails_erd/domain.rb
CHANGED
|
@@ -128,6 +128,7 @@ module RailsERD
|
|
|
128
128
|
.reject { |model| tableless_rails_models.include?(model) }
|
|
129
129
|
.select { |model| check_model_validity(model) }
|
|
130
130
|
.reject { |model| check_habtm_model(model) }
|
|
131
|
+
.sort_by { |model| model.name.to_s }
|
|
131
132
|
end
|
|
132
133
|
|
|
133
134
|
# Returns Rails model classes defined in the app
|
|
@@ -187,7 +188,39 @@ module RailsERD
|
|
|
187
188
|
def excluded_model?(model)
|
|
188
189
|
return false unless options.exclude.present?
|
|
189
190
|
|
|
190
|
-
[options.exclude].flatten
|
|
191
|
+
patterns = [options.exclude].flatten
|
|
192
|
+
patterns.any? { |pattern| matches_pattern?(pattern, model.name) }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Matches a name against a pattern. Supports three pattern types:
|
|
196
|
+
#
|
|
197
|
+
# - Exact match: "Foo" matches only "Foo"
|
|
198
|
+
# - Glob pattern: "SolidQueue::*" matches "SolidQueue::Job", etc.
|
|
199
|
+
# - Regex pattern: "/^Active/" matches "ActiveRecord", "ActiveStorage::Blob"
|
|
200
|
+
#
|
|
201
|
+
def matches_pattern?(pattern, name)
|
|
202
|
+
pattern_str = pattern.to_s
|
|
203
|
+
|
|
204
|
+
# Regex pattern: /pattern/ or /pattern/flags
|
|
205
|
+
#
|
|
206
|
+
if pattern_str.start_with?("/") && pattern_str =~ %r{\A/(.+)/([imx]*)\z}
|
|
207
|
+
regex_body = Regexp.last_match(1)
|
|
208
|
+
flags_str = Regexp.last_match(2)
|
|
209
|
+
flags = 0
|
|
210
|
+
flags |= Regexp::IGNORECASE if flags_str.include?("i")
|
|
211
|
+
flags |= Regexp::MULTILINE if flags_str.include?("m")
|
|
212
|
+
flags |= Regexp::EXTENDED if flags_str.include?("x")
|
|
213
|
+
return Regexp.new(regex_body, flags).match?(name.to_s)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Glob pattern: contains *, ?, or [
|
|
217
|
+
#
|
|
218
|
+
if pattern_str.include?("*") || pattern_str.include?("?") || pattern_str.include?("[")
|
|
219
|
+
return File.fnmatch?(pattern_str, name.to_s)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Exact match (backward compatible)
|
|
223
|
+
pattern_str == name.to_s
|
|
191
224
|
end
|
|
192
225
|
|
|
193
226
|
def check_association_validity(association)
|
|
@@ -207,14 +240,17 @@ module RailsERD
|
|
|
207
240
|
def excluded_association?(association)
|
|
208
241
|
return false unless options.exclude.present?
|
|
209
242
|
|
|
210
|
-
|
|
243
|
+
patterns = [options.exclude].flatten
|
|
211
244
|
|
|
212
245
|
# Suppress warning if either the source model or target model is excluded
|
|
213
|
-
|
|
214
|
-
|
|
246
|
+
source_name = association.active_record.name
|
|
247
|
+
return true if patterns.any? { |pattern| matches_pattern?(pattern, source_name) }
|
|
248
|
+
|
|
249
|
+
target_name = association.options[:polymorphic] ? association.class_name : association.klass.name
|
|
250
|
+
target_name && patterns.any? { |pattern| matches_pattern?(pattern, target_name) }
|
|
215
251
|
rescue NameError
|
|
216
|
-
# If we can't determine the target class,
|
|
217
|
-
|
|
252
|
+
# If we can't determine the target class, the source model was already checked above
|
|
253
|
+
false
|
|
218
254
|
end
|
|
219
255
|
|
|
220
256
|
def check_polymorphic_association_validity(association)
|
data/lib/rails_erd/version.rb
CHANGED
data/lib/rails_erd.rb
CHANGED
data/test/unit/config_test.rb
CHANGED
|
@@ -122,6 +122,17 @@ class ConfigTest < ActiveSupport::TestCase
|
|
|
122
122
|
assert_equal [:content, :primary_keys], normalize_value(:attributes, ["content", "primary_keys"])
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
+
test "normalize_value should canonicalize a hash when key is :exclude_attributes." do
|
|
126
|
+
value = { "BigTable" => true, "User" => ["password_digest"] }
|
|
127
|
+
expected = { "BigTable" => true, "User" => ["password_digest"] }
|
|
128
|
+
assert_equal expected, normalize_value(:exclude_attributes, value)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
test "normalize_value should parse a string when key is :exclude_attributes." do
|
|
132
|
+
expected = { "BigTable" => true, "User" => ["password_digest"] }
|
|
133
|
+
assert_equal expected, normalize_value(:exclude_attributes, "BigTable,User.password_digest")
|
|
134
|
+
end
|
|
135
|
+
|
|
125
136
|
test "normalize_value should return hash with symbol keys when key is :fonts and value is a hash." do
|
|
126
137
|
fonts_value = { "normal" => "Arial", "bold" => "Arial Bold", "italic" => "Arial Italic" }
|
|
127
138
|
expected = {:normal => "Arial", :bold => "Arial Bold", :italic => "Arial Italic"}
|