rails-erd 2.0.2 → 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 +193 -5
- data/Rakefile +1 -1
- data/lib/rails_erd/cli.rb +11 -9
- data/lib/rails_erd/diagram/mermaid.rb +64 -0
- data/lib/rails_erd/diagram/tbls.rb +168 -0
- data/lib/rails_erd/diagram.rb +71 -9
- data/lib/rails_erd/domain.rb +38 -4
- data/lib/rails_erd/version.rb +1 -1
- data/test/unit/diagram_test.rb +109 -1
- data/test/unit/domain_test.rb +104 -0
- data/test/unit/mermaid_test.rb +124 -2
- data/test/unit/tbls_test.rb +152 -0
- metadata +7 -2
|
@@ -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
|
@@ -231,8 +231,7 @@ module RailsERD
|
|
|
231
231
|
|
|
232
232
|
def filtered_entities
|
|
233
233
|
@domain.entities.reject { |entity|
|
|
234
|
-
|
|
235
|
-
options[:only].present? && entity.model && ![options[:only]].flatten.map(&:to_sym).include?(entity.name.to_sym) or
|
|
234
|
+
excluded_by_filter?(entity) or
|
|
236
235
|
!options.inheritance && entity.specialized? or
|
|
237
236
|
!options.polymorphism && entity.generalized? or
|
|
238
237
|
!options.disconnected && entity.disconnected?
|
|
@@ -243,14 +242,75 @@ module RailsERD
|
|
|
243
242
|
|
|
244
243
|
def filtered_relationships
|
|
245
244
|
@domain.relationships.reject { |relationship|
|
|
246
|
-
!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)
|
|
247
251
|
}
|
|
248
252
|
end
|
|
249
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
|
+
|
|
250
304
|
def filtered_specializations
|
|
251
305
|
@domain.specializations.reject { |specialization|
|
|
252
306
|
!options.inheritance && specialization.inheritance? or
|
|
253
|
-
!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?
|
|
254
314
|
}
|
|
255
315
|
end
|
|
256
316
|
|
|
@@ -258,12 +318,14 @@ module RailsERD
|
|
|
258
318
|
excluded = excluded_attributes_for(entity)
|
|
259
319
|
return [] if excluded == :all
|
|
260
320
|
|
|
261
|
-
entity.attributes.
|
|
321
|
+
entity.attributes.select { |attribute|
|
|
262
322
|
# Hide attributes excluded for this specific model.
|
|
263
|
-
excluded.include?(attribute.name)
|
|
264
|
-
#
|
|
265
|
-
|
|
266
|
-
|
|
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')}?") }
|
|
267
329
|
}
|
|
268
330
|
end
|
|
269
331
|
|
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,13 +240,14 @@ 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
|
-
|
|
246
|
+
source_name = association.active_record.name
|
|
247
|
+
return true if patterns.any? { |pattern| matches_pattern?(pattern, source_name) }
|
|
214
248
|
|
|
215
249
|
target_name = association.options[:polymorphic] ? association.class_name : association.klass.name
|
|
216
|
-
target_name &&
|
|
250
|
+
target_name && patterns.any? { |pattern| matches_pattern?(pattern, target_name) }
|
|
217
251
|
rescue NameError
|
|
218
252
|
# If we can't determine the target class, the source model was already checked above
|
|
219
253
|
false
|
data/lib/rails_erd/version.rb
CHANGED
data/test/unit/diagram_test.rb
CHANGED
|
@@ -165,6 +165,76 @@ class DiagramTest < ActiveSupport::TestCase
|
|
|
165
165
|
assert_equal [Author, Editor], retrieve_entities(:only => ['Author', 'Editor']).map(&:model)
|
|
166
166
|
end
|
|
167
167
|
|
|
168
|
+
test "generate should exclude relationships whose endpoints were removed by :only" do
|
|
169
|
+
create_model "Author"
|
|
170
|
+
create_model "Book", :author => :references do
|
|
171
|
+
belongs_to :author
|
|
172
|
+
has_many :reviews
|
|
173
|
+
end
|
|
174
|
+
create_model "Review", :book => :references do
|
|
175
|
+
belongs_to :book
|
|
176
|
+
end
|
|
177
|
+
relationships = retrieve_relationships(:only => [:Author, :Book])
|
|
178
|
+
assert_equal [Set[Author, Book]], relationships.map { |r| Set[r.source.model, r.destination.model] }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
test "generate should exclude relationships to an excluded entity" do
|
|
182
|
+
create_model "Author"
|
|
183
|
+
create_model "Book", :author => :references do
|
|
184
|
+
belongs_to :author
|
|
185
|
+
has_many :reviews
|
|
186
|
+
end
|
|
187
|
+
create_model "Review", :book => :references do
|
|
188
|
+
belongs_to :book
|
|
189
|
+
end
|
|
190
|
+
relationships = retrieve_relationships(:exclude => [:Review])
|
|
191
|
+
assert_equal [Set[Author, Book]], relationships.map { |r| Set[r.source.model, r.destination.model] }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Pattern matching for exclude/only ==========================================
|
|
195
|
+
test "generate should filter entities matching glob pattern in exclude" do
|
|
196
|
+
create_module_model "SolidQueue::Job"
|
|
197
|
+
create_module_model "SolidQueue::Process"
|
|
198
|
+
create_model "User"
|
|
199
|
+
assert_equal [User], retrieve_entities(:exclude => ["SolidQueue::*"]).map(&:model)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
test "generate should filter entities matching regex pattern in exclude" do
|
|
203
|
+
create_module_model "SolidQueue::Job"
|
|
204
|
+
create_model "User"
|
|
205
|
+
assert_equal [User], retrieve_entities(:exclude => ["/^Solid/"]).map(&:model)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
test "generate should include only entities matching glob pattern in only" do
|
|
209
|
+
create_module_model "MyApp::User"
|
|
210
|
+
create_module_model "MyApp::Post"
|
|
211
|
+
create_model "SomeOther"
|
|
212
|
+
entities = retrieve_entities(:only => ["MyApp::*"]).map(&:model)
|
|
213
|
+
assert_includes entities, MyApp::User
|
|
214
|
+
assert_includes entities, MyApp::Post
|
|
215
|
+
refute_includes entities, SomeOther
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
test "generate should include only entities matching regex pattern in only" do
|
|
219
|
+
create_model "AdminUser"
|
|
220
|
+
create_model "AdminPost"
|
|
221
|
+
create_model "GuestUser"
|
|
222
|
+
entities = retrieve_entities(:only => ["/^Admin/"]).map(&:model)
|
|
223
|
+
assert_includes entities, AdminUser
|
|
224
|
+
assert_includes entities, AdminPost
|
|
225
|
+
refute_includes entities, GuestUser
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
test "generate should exclude relationships when endpoint matches glob pattern" do
|
|
229
|
+
create_module_model "SolidQueue::Job"
|
|
230
|
+
create_model "Task", :solid_queue_job => :references do
|
|
231
|
+
belongs_to :solid_queue_job, :class_name => "SolidQueue::Job"
|
|
232
|
+
end
|
|
233
|
+
create_model "User"
|
|
234
|
+
relationships = retrieve_relationships(:exclude => ["SolidQueue::*"])
|
|
235
|
+
assert_equal [], relationships
|
|
236
|
+
end
|
|
237
|
+
|
|
168
238
|
test "generate should filter disconnected entities if disconnected is false" do
|
|
169
239
|
create_model "Book", :author => :references do
|
|
170
240
|
belongs_to :author
|
|
@@ -235,7 +305,7 @@ class DiagramTest < ActiveSupport::TestCase
|
|
|
235
305
|
create_model "Baz", :foo => :references do
|
|
236
306
|
belongs_to :foo
|
|
237
307
|
end
|
|
238
|
-
assert_equal
|
|
308
|
+
assert_equal({ false => 2, true => 1 }, retrieve_relationships(:indirect => true).map(&:indirect?).tally)
|
|
239
309
|
end
|
|
240
310
|
|
|
241
311
|
test "generate should filter indirect relationships if indirect is false" do
|
|
@@ -302,6 +372,21 @@ class DiagramTest < ActiveSupport::TestCase
|
|
|
302
372
|
:polymorphism => true).map { |s| s.specialized.name }
|
|
303
373
|
end
|
|
304
374
|
|
|
375
|
+
test "generate should not yield specializations whose entity is not part of the domain" do
|
|
376
|
+
# An abstract parent whose child model has no table (e.g. ActionMailbox::Record
|
|
377
|
+
# with a tableless ActionMailbox::InboundEmail): the child is excluded from the
|
|
378
|
+
# domain, so the specialization resolves to a nameless Null entity. It must not
|
|
379
|
+
# be yielded, otherwise generators emit an edge to a nameless entity (which is
|
|
380
|
+
# invalid Mermaid output).
|
|
381
|
+
Object.const_set "GhostRecord", Class.new(ActiveRecord::Base) { self.abstract_class = true }
|
|
382
|
+
Object.const_set "GhostThing", Class.new(GhostRecord)
|
|
383
|
+
|
|
384
|
+
specializations = retrieve_specializations(:inheritance => true, :polymorphism => true)
|
|
385
|
+
assert_equal [], specializations.select { |s|
|
|
386
|
+
s.generalized.name.to_s.empty? || s.specialized.name.to_s.empty?
|
|
387
|
+
}
|
|
388
|
+
end
|
|
389
|
+
|
|
305
390
|
# Attribute filtering ======================================================
|
|
306
391
|
test "generate should yield content attributes by default" do
|
|
307
392
|
create_model "Book", :title => :string, :created_at => :datetime, :author => :references do
|
|
@@ -388,4 +473,27 @@ class DiagramTest < ActiveSupport::TestCase
|
|
|
388
473
|
assert_equal %w{title}, attribute_lists[Book].map(&:name)
|
|
389
474
|
assert_equal [], attribute_lists[Author].map(&:name)
|
|
390
475
|
end
|
|
476
|
+
|
|
477
|
+
test "normalize_exclude_attributes should split namespaced models on the first dot only" do
|
|
478
|
+
assert_equal({ "Admin::User" => ["password_digest"] },
|
|
479
|
+
Diagram.normalize_exclude_attributes("Admin::User.password_digest"))
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
test "normalize_exclude_attributes should treat a bare namespaced model as hide all" do
|
|
483
|
+
assert_equal({ "Admin::User" => true },
|
|
484
|
+
Diagram.normalize_exclude_attributes("Admin::User"))
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
test "normalize_exclude_attributes should return an empty hash for nil" do
|
|
488
|
+
assert_equal({}, Diagram.normalize_exclude_attributes(nil))
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
test "normalize_exclude_attributes should return an empty hash for false" do
|
|
492
|
+
assert_equal({}, Diagram.normalize_exclude_attributes(false))
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
test "normalize_exclude_attributes should ignore blank entries in a string" do
|
|
496
|
+
assert_equal({ "Book" => true },
|
|
497
|
+
Diagram.normalize_exclude_attributes("Book, ,"))
|
|
498
|
+
end
|
|
391
499
|
end
|