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/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/lib/rails_erd.rb
CHANGED
|
@@ -51,12 +51,14 @@ module RailsERD
|
|
|
51
51
|
:notation, :simple,
|
|
52
52
|
:orientation, :horizontal,
|
|
53
53
|
:polymorphism, false,
|
|
54
|
+
:recursive, true,
|
|
54
55
|
:sort, true,
|
|
55
56
|
:warn, true,
|
|
56
57
|
:title, true,
|
|
57
58
|
:exclude, nil,
|
|
58
59
|
:exclude_attributes, nil,
|
|
59
60
|
:only, nil,
|
|
61
|
+
:only_attributes, nil,
|
|
60
62
|
:only_recursion_depth, nil,
|
|
61
63
|
:prepend_primary, false,
|
|
62
64
|
:cluster, false,
|
data/test/test_helper.rb
CHANGED
data/test/unit/config_test.rb
CHANGED
|
@@ -133,6 +133,17 @@ class ConfigTest < ActiveSupport::TestCase
|
|
|
133
133
|
assert_equal expected, normalize_value(:exclude_attributes, "BigTable,User.password_digest")
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
+
test "normalize_value should canonicalize a hash when key is :only_attributes." do
|
|
137
|
+
value = { "Book" => ["title", "isbn"] }
|
|
138
|
+
expected = { "Book" => ["title", "isbn"] }
|
|
139
|
+
assert_equal expected, normalize_value(:only_attributes, value)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
test "normalize_value should parse a string when key is :only_attributes." do
|
|
143
|
+
expected = { "Book" => ["title", "isbn"] }
|
|
144
|
+
assert_equal expected, normalize_value(:only_attributes, "Book.title,Book.isbn")
|
|
145
|
+
end
|
|
146
|
+
|
|
136
147
|
test "normalize_value should return hash with symbol keys when key is :fonts and value is a hash." do
|
|
137
148
|
fonts_value = { "normal" => "Arial", "bold" => "Arial Bold", "italic" => "Arial Italic" }
|
|
138
149
|
expected = {:normal => "Arial", :bold => "Arial Bold", :italic => "Arial Italic"}
|
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
|
|
@@ -253,6 +323,20 @@ class DiagramTest < ActiveSupport::TestCase
|
|
|
253
323
|
assert_equal [false, false], retrieve_relationships(:indirect => false).map(&:indirect?)
|
|
254
324
|
end
|
|
255
325
|
|
|
326
|
+
test "generate should yield self referential relationships if recursive is true" do
|
|
327
|
+
create_model "Node", :parent => :references do
|
|
328
|
+
belongs_to :parent, :class_name => "Node"
|
|
329
|
+
end
|
|
330
|
+
assert_equal [true], retrieve_relationships(:recursive => true).map(&:recursive?)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
test "generate should filter self referential relationships if recursive is false" do
|
|
334
|
+
create_model "Node", :parent => :references do
|
|
335
|
+
belongs_to :parent, :class_name => "Node"
|
|
336
|
+
end
|
|
337
|
+
assert_equal [], retrieve_relationships(:recursive => false)
|
|
338
|
+
end
|
|
339
|
+
|
|
256
340
|
test "generate should yield relationships from specialized entities" do
|
|
257
341
|
create_model "Foo", :bar => :references
|
|
258
342
|
create_model "Bar", :type => :string
|
|
@@ -302,6 +386,21 @@ class DiagramTest < ActiveSupport::TestCase
|
|
|
302
386
|
:polymorphism => true).map { |s| s.specialized.name }
|
|
303
387
|
end
|
|
304
388
|
|
|
389
|
+
test "generate should not yield specializations whose entity is not part of the domain" do
|
|
390
|
+
# An abstract parent whose child model has no table (e.g. ActionMailbox::Record
|
|
391
|
+
# with a tableless ActionMailbox::InboundEmail): the child is excluded from the
|
|
392
|
+
# domain, so the specialization resolves to a nameless Null entity. It must not
|
|
393
|
+
# be yielded, otherwise generators emit an edge to a nameless entity (which is
|
|
394
|
+
# invalid Mermaid output).
|
|
395
|
+
Object.const_set "GhostRecord", Class.new(ActiveRecord::Base) { self.abstract_class = true }
|
|
396
|
+
Object.const_set "GhostThing", Class.new(GhostRecord)
|
|
397
|
+
|
|
398
|
+
specializations = retrieve_specializations(:inheritance => true, :polymorphism => true)
|
|
399
|
+
assert_equal [], specializations.select { |s|
|
|
400
|
+
s.generalized.name.to_s.empty? || s.specialized.name.to_s.empty?
|
|
401
|
+
}
|
|
402
|
+
end
|
|
403
|
+
|
|
305
404
|
# Attribute filtering ======================================================
|
|
306
405
|
test "generate should yield content attributes by default" do
|
|
307
406
|
create_model "Book", :title => :string, :created_at => :datetime, :author => :references do
|
|
@@ -388,4 +487,95 @@ class DiagramTest < ActiveSupport::TestCase
|
|
|
388
487
|
assert_equal %w{title}, attribute_lists[Book].map(&:name)
|
|
389
488
|
assert_equal [], attribute_lists[Author].map(&:name)
|
|
390
489
|
end
|
|
490
|
+
|
|
491
|
+
test "generate should show only the listed attributes for a model" do
|
|
492
|
+
create_model "Book", :title => :string, :subtitle => :string, :pages => :integer
|
|
493
|
+
attribute_lists = retrieve_attribute_lists(:only_attributes => { "Book" => ["title", "pages"] })
|
|
494
|
+
assert_equal %w{pages title}, attribute_lists[Book].map(&:name).sort
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
test "generate should restrict attributes for the listed model only" do
|
|
498
|
+
create_model "Book", :title => :string, :subtitle => :string
|
|
499
|
+
create_model "Author", :name => :string, :born_on => :date
|
|
500
|
+
attribute_lists = retrieve_attribute_lists(:only_attributes => { "Book" => ["title"] })
|
|
501
|
+
assert_equal %w{title}, attribute_lists[Book].map(&:name)
|
|
502
|
+
assert_equal %w{born_on name}, attribute_lists[Author].map(&:name).sort
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
test "generate should accept only_attributes as a string" do
|
|
506
|
+
create_model "Book", :title => :string, :subtitle => :string
|
|
507
|
+
create_model "Author", :name => :string
|
|
508
|
+
attribute_lists = retrieve_attribute_lists(:only_attributes => "Book.title")
|
|
509
|
+
assert_equal %w{title}, attribute_lists[Book].map(&:name)
|
|
510
|
+
assert_equal %w{name}, attribute_lists[Author].map(&:name)
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
test "generate should keep all attributes for a model listed in only_attributes without any attribute" do
|
|
514
|
+
create_model "Book", :title => :string, :subtitle => :string
|
|
515
|
+
attribute_lists = retrieve_attribute_lists(:only_attributes => "Book")
|
|
516
|
+
assert_equal %w{subtitle title}, attribute_lists[Book].map(&:name).sort
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
test "generate should not show attributes selected by only_attributes but rejected by attributes" do
|
|
520
|
+
create_model "Book", :title => :string, :created_at => :datetime
|
|
521
|
+
attribute_lists = retrieve_attribute_lists(:only_attributes => { "Book" => ["title", "created_at"] },
|
|
522
|
+
:attributes => [:content])
|
|
523
|
+
assert_equal %w{title}, attribute_lists[Book].map(&:name)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
test "generate should apply only_attributes before exclude_attributes" do
|
|
527
|
+
create_model "Book", :title => :string, :subtitle => :string, :pages => :integer
|
|
528
|
+
attribute_lists = retrieve_attribute_lists(:only_attributes => { "Book" => ["title", "subtitle"] },
|
|
529
|
+
:exclude_attributes => { "Book" => ["subtitle"] })
|
|
530
|
+
assert_equal %w{title}, attribute_lists[Book].map(&:name)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
test "generate should hide all attributes when a model is in both only_attributes and fully excluded" do
|
|
534
|
+
create_model "Book", :title => :string, :subtitle => :string
|
|
535
|
+
attribute_lists = retrieve_attribute_lists(:only_attributes => { "Book" => ["title"] },
|
|
536
|
+
:exclude_attributes => { "Book" => true })
|
|
537
|
+
assert_equal [], attribute_lists[Book].map(&:name)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
test "normalize_only_attributes should split namespaced models on the first dot only" do
|
|
541
|
+
assert_equal({ "Admin::User" => ["email"] },
|
|
542
|
+
Diagram.normalize_only_attributes("Admin::User.email"))
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
test "normalize_only_attributes should return an empty hash for nil" do
|
|
546
|
+
assert_equal({}, Diagram.normalize_only_attributes(nil))
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
test "normalize_only_attributes should return an empty hash for false" do
|
|
550
|
+
assert_equal({}, Diagram.normalize_only_attributes(false))
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
test "normalize_only_attributes should raise for true" do
|
|
554
|
+
assert_raises ArgumentError do
|
|
555
|
+
Diagram.normalize_only_attributes(true)
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
test "normalize_exclude_attributes should split namespaced models on the first dot only" do
|
|
560
|
+
assert_equal({ "Admin::User" => ["password_digest"] },
|
|
561
|
+
Diagram.normalize_exclude_attributes("Admin::User.password_digest"))
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
test "normalize_exclude_attributes should treat a bare namespaced model as hide all" do
|
|
565
|
+
assert_equal({ "Admin::User" => true },
|
|
566
|
+
Diagram.normalize_exclude_attributes("Admin::User"))
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
test "normalize_exclude_attributes should return an empty hash for nil" do
|
|
570
|
+
assert_equal({}, Diagram.normalize_exclude_attributes(nil))
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
test "normalize_exclude_attributes should return an empty hash for false" do
|
|
574
|
+
assert_equal({}, Diagram.normalize_exclude_attributes(false))
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
test "normalize_exclude_attributes should ignore blank entries in a string" do
|
|
578
|
+
assert_equal({ "Book" => true },
|
|
579
|
+
Diagram.normalize_exclude_attributes("Book, ,"))
|
|
580
|
+
end
|
|
391
581
|
end
|
data/test/unit/domain_test.rb
CHANGED
|
@@ -151,6 +151,23 @@ class DomainTest < ActiveSupport::TestCase
|
|
|
151
151
|
assert_equal ["Many", "More"], [relationship.source.name, relationship.destination.name]
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
+
test "relationships should be deterministic regardless of model order" do
|
|
155
|
+
create_model "Author"
|
|
156
|
+
create_model "Book", :author => :references do
|
|
157
|
+
belongs_to :author
|
|
158
|
+
has_many :reviews
|
|
159
|
+
end
|
|
160
|
+
create_model "Review", :book => :references do
|
|
161
|
+
belongs_to :book
|
|
162
|
+
end
|
|
163
|
+
pairs = lambda do |models|
|
|
164
|
+
Domain.new(models).relationships.collect { |r| [r.source.name, r.destination.name] }
|
|
165
|
+
end
|
|
166
|
+
models = [Author, Book, Review]
|
|
167
|
+
# Order and source/destination direction must not depend on the input model order.
|
|
168
|
+
assert_equal pairs.call(models), pairs.call(models.reverse)
|
|
169
|
+
end
|
|
170
|
+
|
|
154
171
|
# Specialization processing ================================================
|
|
155
172
|
test "specializations should return empty array for empty domain" do
|
|
156
173
|
assert_equal [], Domain.generate.specializations
|
|
@@ -301,4 +318,91 @@ class DomainTest < ActiveSupport::TestCase
|
|
|
301
318
|
end
|
|
302
319
|
assert_equal "", output
|
|
303
320
|
end
|
|
321
|
+
|
|
322
|
+
# Pattern matching for exclude/only ==========================================
|
|
323
|
+
test "matches_pattern? should match exact string" do
|
|
324
|
+
domain = Domain.new([])
|
|
325
|
+
assert domain.send(:matches_pattern?, "Foo", "Foo")
|
|
326
|
+
refute domain.send(:matches_pattern?, "Foo", "Bar")
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
test "matches_pattern? should match glob pattern with asterisk" do
|
|
330
|
+
domain = Domain.new([])
|
|
331
|
+
assert domain.send(:matches_pattern?, "SolidQueue::*", "SolidQueue::Job")
|
|
332
|
+
assert domain.send(:matches_pattern?, "SolidQueue::*", "SolidQueue::Process")
|
|
333
|
+
refute domain.send(:matches_pattern?, "SolidQueue::*", "SolidCache::Entry")
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
test "matches_pattern? should match glob pattern without namespace separator" do
|
|
337
|
+
domain = Domain.new([])
|
|
338
|
+
assert domain.send(:matches_pattern?, "ActiveStorage*", "ActiveStorageBlob")
|
|
339
|
+
assert domain.send(:matches_pattern?, "ActiveStorage*", "ActiveStorage::Blob")
|
|
340
|
+
refute domain.send(:matches_pattern?, "ActiveStorage*", "ActionMailbox::InboundEmail")
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
test "matches_pattern? should match regex pattern" do
|
|
344
|
+
domain = Domain.new([])
|
|
345
|
+
assert domain.send(:matches_pattern?, "/^Active/", "ActiveRecord")
|
|
346
|
+
assert domain.send(:matches_pattern?, "/^Active/", "ActiveStorage::Blob")
|
|
347
|
+
refute domain.send(:matches_pattern?, "/^Active/", "SolidQueue::Job")
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
test "matches_pattern? should match regex pattern with flags" do
|
|
351
|
+
domain = Domain.new([])
|
|
352
|
+
assert domain.send(:matches_pattern?, "/queue/i", "SolidQueue::Job")
|
|
353
|
+
refute domain.send(:matches_pattern?, "/queue/", "SolidQueue::Job")
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
test "excluded_model? should match glob patterns" do
|
|
357
|
+
create_module_model "SolidQueue::Job"
|
|
358
|
+
create_module_model "SolidQueue::Process"
|
|
359
|
+
create_model "User"
|
|
360
|
+
|
|
361
|
+
domain = Domain.generate(:exclude => ["SolidQueue::*"])
|
|
362
|
+
|
|
363
|
+
# excluded_model? returns true for matching patterns
|
|
364
|
+
assert domain.send(:excluded_model?, SolidQueue::Job)
|
|
365
|
+
assert domain.send(:excluded_model?, SolidQueue::Process)
|
|
366
|
+
refute domain.send(:excluded_model?, User)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
test "excluded_model? should match regex patterns" do
|
|
370
|
+
create_module_model "SolidQueue::Job"
|
|
371
|
+
create_model "User"
|
|
372
|
+
|
|
373
|
+
domain = Domain.generate(:exclude => ["/^Solid/"])
|
|
374
|
+
assert domain.send(:excluded_model?, SolidQueue::Job)
|
|
375
|
+
refute domain.send(:excluded_model?, User)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
test "excluded_model? should still match exact names for backward compatibility" do
|
|
379
|
+
create_model "Foo"
|
|
380
|
+
create_model "Bar"
|
|
381
|
+
|
|
382
|
+
domain = Domain.generate(:exclude => ["Foo"])
|
|
383
|
+
assert domain.send(:excluded_model?, Foo)
|
|
384
|
+
refute domain.send(:excluded_model?, Bar)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
test "excluded_association? should match glob patterns for source model" do
|
|
388
|
+
create_module_model "SolidQueue::Job" do
|
|
389
|
+
has_many :executions
|
|
390
|
+
end
|
|
391
|
+
create_model "User"
|
|
392
|
+
|
|
393
|
+
domain = Domain.generate(:exclude => ["SolidQueue::*"], :warn => false)
|
|
394
|
+
association = SolidQueue::Job.reflect_on_association(:executions)
|
|
395
|
+
assert domain.send(:excluded_association?, association)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
test "excluded_association? should match glob patterns for target model" do
|
|
399
|
+
create_module_model "SolidQueue::Execution"
|
|
400
|
+
create_model "Task" do
|
|
401
|
+
has_many :executions, :class_name => "SolidQueue::Execution"
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
domain = Domain.generate(:exclude => ["SolidQueue::*"], :warn => false)
|
|
405
|
+
association = Task.reflect_on_association(:executions)
|
|
406
|
+
assert domain.send(:excluded_association?, association)
|
|
407
|
+
end
|
|
304
408
|
end
|
data/test/unit/mermaid_test.rb
CHANGED
|
@@ -188,13 +188,30 @@ class MermaidTest < ActiveSupport::TestCase
|
|
|
188
188
|
"\tclass `Cannon`",
|
|
189
189
|
"\tclass `Galleon`",
|
|
190
190
|
"\tclass `Stronghold`",
|
|
191
|
-
"\t`Defensible` --> `Cannon`",
|
|
192
191
|
"\t`Galleon` --> `Cannon`",
|
|
193
192
|
"\t`Stronghold` --> `Cannon`"
|
|
194
193
|
]
|
|
195
194
|
assert_equal expected, diagram.graph.uniq
|
|
196
195
|
end
|
|
197
196
|
|
|
197
|
+
test "generate should not draw polymorphic entity that is omitted by only" do
|
|
198
|
+
create_model "Cannon", :defensible => :references do
|
|
199
|
+
belongs_to :defensible, :polymorphic => true
|
|
200
|
+
end
|
|
201
|
+
create_model "Galleon" do
|
|
202
|
+
has_many :cannons, :as => :defensible
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
expected = [
|
|
206
|
+
"classDiagram",
|
|
207
|
+
"\tdirection TB",
|
|
208
|
+
"\tclass `Cannon`",
|
|
209
|
+
"\tclass `Galleon`",
|
|
210
|
+
"\t`Galleon` --> `Cannon`"
|
|
211
|
+
]
|
|
212
|
+
assert_equal expected, diagram(:only => %w{Cannon Galleon}).graph.uniq
|
|
213
|
+
end
|
|
214
|
+
|
|
198
215
|
test "generate should support one to many relationships" do
|
|
199
216
|
create_one_to_many_assoc_domain
|
|
200
217
|
|
|
@@ -230,9 +247,9 @@ class MermaidTest < ActiveSupport::TestCase
|
|
|
230
247
|
"\tclass `Bar`",
|
|
231
248
|
"\tclass `Baz`",
|
|
232
249
|
"\tclass `Foo`",
|
|
233
|
-
"\t`Foo` --> `Baz`",
|
|
234
250
|
"\t`Foo` --> `Bar`",
|
|
235
|
-
"\t`Bar` ..> `Baz`"
|
|
251
|
+
"\t`Bar` ..> `Baz`",
|
|
252
|
+
"\t`Foo` --> `Baz`"
|
|
236
253
|
]
|
|
237
254
|
|
|
238
255
|
assert_equal expected, diagram.graph.uniq
|
|
@@ -437,4 +454,126 @@ class MermaidTest < ActiveSupport::TestCase
|
|
|
437
454
|
# Verify the specialization relationship line also quotes it
|
|
438
455
|
assert result.match?(/Vehicle.*--.*"Transport::Car"/), "Specialization relationship should quote namespaced entity"
|
|
439
456
|
end
|
|
457
|
+
|
|
458
|
+
# Namespace clustering tests (Issue #479) =====================================
|
|
459
|
+
|
|
460
|
+
test "classDiagram with cluster should group entities by namespace" do
|
|
461
|
+
create_model "Post"
|
|
462
|
+
create_module_model "Admin::Author", :post => :references do
|
|
463
|
+
belongs_to :post
|
|
464
|
+
end
|
|
465
|
+
create_module_model "Admin::Role"
|
|
466
|
+
|
|
467
|
+
result = diagram(:cluster => true).graph.join("\n")
|
|
468
|
+
|
|
469
|
+
assert result.include?("classDiagram")
|
|
470
|
+
# Should have namespace block for Admin
|
|
471
|
+
assert result.include?("namespace Admin {"), "Should have namespace block for Admin"
|
|
472
|
+
# Author and Role should be inside the Admin namespace
|
|
473
|
+
assert result.match?(/namespace Admin \{[^}]*class `Author`/m), "Author should be inside Admin namespace"
|
|
474
|
+
assert result.match?(/namespace Admin \{[^}]*class `Role`/m), "Role should be inside Admin namespace"
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
test "classDiagram with cluster should place entities without namespace outside blocks" do
|
|
478
|
+
create_model "Post"
|
|
479
|
+
create_module_model "Admin::Author", :post => :references do
|
|
480
|
+
belongs_to :post
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
result = diagram(:cluster => true).graph.join("\n")
|
|
484
|
+
|
|
485
|
+
# Post should appear before any namespace block
|
|
486
|
+
post_index = result.index("class `Post`")
|
|
487
|
+
namespace_index = result.index("namespace Admin {")
|
|
488
|
+
assert post_index < namespace_index, "Entities without namespace should appear before namespace blocks"
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
test "classDiagram with cluster should handle nested namespaces" do
|
|
492
|
+
create_model "Post"
|
|
493
|
+
create_module_model "Admin::Users::Role", :post => :references do
|
|
494
|
+
belongs_to :post
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
result = diagram(:cluster => true).graph.join("\n")
|
|
498
|
+
|
|
499
|
+
# Nested namespace should use dot notation (Admin.Users)
|
|
500
|
+
assert result.include?("namespace Admin.Users {"), "Should convert :: to . in namespace names"
|
|
501
|
+
assert result.match?(/namespace Admin\.Users \{[^}]*class `Role`/m), "Role should be inside Admin.Users namespace"
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
test "classDiagram with cluster should render relationships outside namespace blocks" do
|
|
505
|
+
create_model "Post"
|
|
506
|
+
create_module_model "Admin::Author", :post => :references do
|
|
507
|
+
belongs_to :post
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
result = diagram(:cluster => true).graph.join("\n")
|
|
511
|
+
|
|
512
|
+
# Relationship should appear after the namespace block closes
|
|
513
|
+
namespace_close_index = result.index("}")
|
|
514
|
+
relationship_index = result.index("`Post` --> `Admin::Author`")
|
|
515
|
+
assert relationship_index > namespace_close_index, "Relationships should appear after namespace blocks"
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
test "erDiagram with cluster should emit warning and render flat" do
|
|
519
|
+
create_model "Post"
|
|
520
|
+
create_module_model "Admin::Author", :post => :references do
|
|
521
|
+
belongs_to :post
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
test_diagram = nil
|
|
525
|
+
|
|
526
|
+
warning_output = collect_stdout do
|
|
527
|
+
domain = Domain.generate
|
|
528
|
+
test_diagram = Diagram::Mermaid.new(domain, :cluster => true, :mermaid_style => :erdiagram, :warn => true)
|
|
529
|
+
test_diagram.generate
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
result = test_diagram.graph.join("\n")
|
|
533
|
+
|
|
534
|
+
# Should emit warning about clustering not supported
|
|
535
|
+
assert warning_output.include?("Clustering is not supported"), "Should warn about clustering not supported in erDiagram"
|
|
536
|
+
# Should still render the diagram (flat, no namespace blocks)
|
|
537
|
+
assert result.include?("erDiagram")
|
|
538
|
+
refute result.include?("namespace"), "erDiagram should not have namespace blocks"
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
test "classDiagram with cluster false should not group entities" do
|
|
542
|
+
create_model "Post"
|
|
543
|
+
create_module_model "Admin::Author", :post => :references do
|
|
544
|
+
belongs_to :post
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
result = diagram(:cluster => false).graph.join("\n")
|
|
548
|
+
|
|
549
|
+
assert result.include?("classDiagram")
|
|
550
|
+
# Should NOT have namespace blocks
|
|
551
|
+
refute result.include?("namespace"), "cluster: false should not create namespace blocks"
|
|
552
|
+
# Should use full entity names
|
|
553
|
+
assert result.include?("class `Admin::Author`"), "Should use full entity name without clustering"
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
test "classDiagram with cluster and inheritance should emit entities before specializations" do
|
|
557
|
+
create_model "Vehicle", :type => :string
|
|
558
|
+
create_module_model "Transport::Car", Vehicle
|
|
559
|
+
|
|
560
|
+
result = diagram(:cluster => true, :inheritance => true).graph.join("\n")
|
|
561
|
+
|
|
562
|
+
# Entity class definitions should appear BEFORE specialization lines
|
|
563
|
+
vehicle_class_index = result.index("class `Vehicle`")
|
|
564
|
+
polymorphic_index = result.index("<<polymorphic>>")
|
|
565
|
+
inheritance_index = result.index("<|--")
|
|
566
|
+
|
|
567
|
+
assert vehicle_class_index, "Should have Vehicle class definition.\nGot:\n#{result}"
|
|
568
|
+
assert polymorphic_index, "Should have polymorphic marker.\nGot:\n#{result}"
|
|
569
|
+
assert inheritance_index, "Should have inheritance relationship.\nGot:\n#{result}"
|
|
570
|
+
|
|
571
|
+
# The key assertion: class definitions must come BEFORE specializations
|
|
572
|
+
assert vehicle_class_index < polymorphic_index,
|
|
573
|
+
"Class definitions should appear before polymorphic markers.\n" \
|
|
574
|
+
"Got:\n#{result}"
|
|
575
|
+
assert vehicle_class_index < inheritance_index,
|
|
576
|
+
"Class definitions should appear before inheritance relationships.\n" \
|
|
577
|
+
"Got:\n#{result}"
|
|
578
|
+
end
|
|
440
579
|
end
|