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.
@@ -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
@@ -231,8 +231,7 @@ module RailsERD
231
231
 
232
232
  def filtered_entities
233
233
  @domain.entities.reject { |entity|
234
- options.exclude.present? && [options.exclude].flatten.map(&:to_sym).include?(entity.name.to_sym) or
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.reject { |attribute|
321
+ entity.attributes.select { |attribute|
262
322
  # Hide attributes excluded for this specific model.
263
- excluded.include?(attribute.name) or
264
- # Select attributes that satisfy the conditions in the :attributes option.
265
- !options.attributes or entity.specialized? or
266
- [*options.attributes].none? { |type| attribute.send(:"#{type.to_s.chomp('s')}?") }
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
 
@@ -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.map(&:to_sym).include?(model.name.to_sym)
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
- excluded_names = [options.exclude].flatten.map(&:to_sym)
243
+ patterns = [options.exclude].flatten
211
244
 
212
245
  # Suppress warning if either the source model or target model is excluded
213
- return true if excluded_names.include?(association.active_record.name.to_sym)
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 && excluded_names.include?(target_name.to_sym)
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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsERD
4
- VERSION = "2.0.2"
4
+ VERSION = "2.1.0"
5
5
  BANNER = "RailsERD #{VERSION}"
6
6
  end
@@ -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 [false, false, true], retrieve_relationships(:indirect => true).map(&:indirect?)
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