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/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
|
|
@@ -358,4 +443,57 @@ class DiagramTest < ActiveSupport::TestCase
|
|
|
358
443
|
Object.const_set :Whisky, Class.new(Beverage)
|
|
359
444
|
assert_equal [], retrieve_attribute_lists(:inheritance => true)[Whisky].map(&:name)
|
|
360
445
|
end
|
|
446
|
+
|
|
447
|
+
test "generate should hide all attributes for a model excluded with true" do
|
|
448
|
+
create_model "Book", :title => :string, :pages => :integer
|
|
449
|
+
create_model "Author", :name => :string
|
|
450
|
+
attribute_lists = retrieve_attribute_lists(:exclude_attributes => { "Book" => true })
|
|
451
|
+
assert_equal [], attribute_lists[Book].map(&:name)
|
|
452
|
+
assert_equal %w{name}, attribute_lists[Author].map(&:name)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
test "generate should hide only listed attributes for a model" do
|
|
456
|
+
create_model "Book", :title => :string, :subtitle => :string, :pages => :integer
|
|
457
|
+
attribute_lists = retrieve_attribute_lists(:exclude_attributes => { "Book" => ["subtitle"] })
|
|
458
|
+
assert_equal %w{pages title}, attribute_lists[Book].map(&:name).sort
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
test "generate should hide attributes for the listed model only" do
|
|
462
|
+
create_model "Book", :title => :string
|
|
463
|
+
create_model "Author", :name => :string
|
|
464
|
+
attribute_lists = retrieve_attribute_lists(:exclude_attributes => { "Book" => ["title"] })
|
|
465
|
+
assert_equal [], attribute_lists[Book].map(&:name)
|
|
466
|
+
assert_equal %w{name}, attribute_lists[Author].map(&:name)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
test "generate should accept exclude_attributes as a string" do
|
|
470
|
+
create_model "Book", :title => :string, :subtitle => :string
|
|
471
|
+
create_model "Author", :name => :string
|
|
472
|
+
attribute_lists = retrieve_attribute_lists(:exclude_attributes => "Book.subtitle,Author")
|
|
473
|
+
assert_equal %w{title}, attribute_lists[Book].map(&:name)
|
|
474
|
+
assert_equal [], attribute_lists[Author].map(&:name)
|
|
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
|
|
361
499
|
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
|
|
@@ -255,6 +272,19 @@ class DomainTest < ActiveSupport::TestCase
|
|
|
255
272
|
assert_match(/polymorphic interface FooBar does not exist/, output)
|
|
256
273
|
end
|
|
257
274
|
|
|
275
|
+
test "relationships should not crash when exclude is present and a polymorphic association's interface does not exist" do
|
|
276
|
+
create_model "Foo" do
|
|
277
|
+
has_many :bars, :as => :foo
|
|
278
|
+
end
|
|
279
|
+
create_model "Bar", :foobar => :references do
|
|
280
|
+
belongs_to :foo_bar, :polymorphic => true
|
|
281
|
+
end
|
|
282
|
+
output = collect_stdout do
|
|
283
|
+
Domain.generate(:exclude => ["Qux"]).relationships
|
|
284
|
+
end
|
|
285
|
+
assert_match(/polymorphic interface FooBar does not exist/, output)
|
|
286
|
+
end
|
|
287
|
+
|
|
258
288
|
test "relationships should not warn when a bad association is encountered if warnings are disabled" do
|
|
259
289
|
create_model "Foo" do
|
|
260
290
|
has_many :flabs
|
|
@@ -288,4 +318,91 @@ class DomainTest < ActiveSupport::TestCase
|
|
|
288
318
|
end
|
|
289
319
|
assert_equal "", output
|
|
290
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
|
|
291
408
|
end
|
data/test/unit/mermaid_test.rb
CHANGED
|
@@ -230,9 +230,9 @@ class MermaidTest < ActiveSupport::TestCase
|
|
|
230
230
|
"\tclass `Bar`",
|
|
231
231
|
"\tclass `Baz`",
|
|
232
232
|
"\tclass `Foo`",
|
|
233
|
-
"\t`Foo` --> `Baz`",
|
|
234
233
|
"\t`Foo` --> `Bar`",
|
|
235
|
-
"\t`Bar` ..> `Baz`"
|
|
234
|
+
"\t`Bar` ..> `Baz`",
|
|
235
|
+
"\t`Foo` --> `Baz`"
|
|
236
236
|
]
|
|
237
237
|
|
|
238
238
|
assert_equal expected, diagram.graph.uniq
|
|
@@ -437,4 +437,126 @@ class MermaidTest < ActiveSupport::TestCase
|
|
|
437
437
|
# Verify the specialization relationship line also quotes it
|
|
438
438
|
assert result.match?(/Vehicle.*--.*"Transport::Car"/), "Specialization relationship should quote namespaced entity"
|
|
439
439
|
end
|
|
440
|
+
|
|
441
|
+
# Namespace clustering tests (Issue #479) =====================================
|
|
442
|
+
|
|
443
|
+
test "classDiagram with cluster should group entities by namespace" do
|
|
444
|
+
create_model "Post"
|
|
445
|
+
create_module_model "Admin::Author", :post => :references do
|
|
446
|
+
belongs_to :post
|
|
447
|
+
end
|
|
448
|
+
create_module_model "Admin::Role"
|
|
449
|
+
|
|
450
|
+
result = diagram(:cluster => true).graph.join("\n")
|
|
451
|
+
|
|
452
|
+
assert result.include?("classDiagram")
|
|
453
|
+
# Should have namespace block for Admin
|
|
454
|
+
assert result.include?("namespace Admin {"), "Should have namespace block for Admin"
|
|
455
|
+
# Author and Role should be inside the Admin namespace
|
|
456
|
+
assert result.match?(/namespace Admin \{[^}]*class `Author`/m), "Author should be inside Admin namespace"
|
|
457
|
+
assert result.match?(/namespace Admin \{[^}]*class `Role`/m), "Role should be inside Admin namespace"
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
test "classDiagram with cluster should place entities without namespace outside blocks" do
|
|
461
|
+
create_model "Post"
|
|
462
|
+
create_module_model "Admin::Author", :post => :references do
|
|
463
|
+
belongs_to :post
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
result = diagram(:cluster => true).graph.join("\n")
|
|
467
|
+
|
|
468
|
+
# Post should appear before any namespace block
|
|
469
|
+
post_index = result.index("class `Post`")
|
|
470
|
+
namespace_index = result.index("namespace Admin {")
|
|
471
|
+
assert post_index < namespace_index, "Entities without namespace should appear before namespace blocks"
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
test "classDiagram with cluster should handle nested namespaces" do
|
|
475
|
+
create_model "Post"
|
|
476
|
+
create_module_model "Admin::Users::Role", :post => :references do
|
|
477
|
+
belongs_to :post
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
result = diagram(:cluster => true).graph.join("\n")
|
|
481
|
+
|
|
482
|
+
# Nested namespace should use dot notation (Admin.Users)
|
|
483
|
+
assert result.include?("namespace Admin.Users {"), "Should convert :: to . in namespace names"
|
|
484
|
+
assert result.match?(/namespace Admin\.Users \{[^}]*class `Role`/m), "Role should be inside Admin.Users namespace"
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
test "classDiagram with cluster should render relationships outside namespace blocks" do
|
|
488
|
+
create_model "Post"
|
|
489
|
+
create_module_model "Admin::Author", :post => :references do
|
|
490
|
+
belongs_to :post
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
result = diagram(:cluster => true).graph.join("\n")
|
|
494
|
+
|
|
495
|
+
# Relationship should appear after the namespace block closes
|
|
496
|
+
namespace_close_index = result.index("}")
|
|
497
|
+
relationship_index = result.index("`Post` --> `Admin::Author`")
|
|
498
|
+
assert relationship_index > namespace_close_index, "Relationships should appear after namespace blocks"
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
test "erDiagram with cluster should emit warning and render flat" do
|
|
502
|
+
create_model "Post"
|
|
503
|
+
create_module_model "Admin::Author", :post => :references do
|
|
504
|
+
belongs_to :post
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
test_diagram = nil
|
|
508
|
+
|
|
509
|
+
warning_output = collect_stdout do
|
|
510
|
+
domain = Domain.generate
|
|
511
|
+
test_diagram = Diagram::Mermaid.new(domain, :cluster => true, :mermaid_style => :erdiagram, :warn => true)
|
|
512
|
+
test_diagram.generate
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
result = test_diagram.graph.join("\n")
|
|
516
|
+
|
|
517
|
+
# Should emit warning about clustering not supported
|
|
518
|
+
assert warning_output.include?("Clustering is not supported"), "Should warn about clustering not supported in erDiagram"
|
|
519
|
+
# Should still render the diagram (flat, no namespace blocks)
|
|
520
|
+
assert result.include?("erDiagram")
|
|
521
|
+
refute result.include?("namespace"), "erDiagram should not have namespace blocks"
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
test "classDiagram with cluster false should not group entities" do
|
|
525
|
+
create_model "Post"
|
|
526
|
+
create_module_model "Admin::Author", :post => :references do
|
|
527
|
+
belongs_to :post
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
result = diagram(:cluster => false).graph.join("\n")
|
|
531
|
+
|
|
532
|
+
assert result.include?("classDiagram")
|
|
533
|
+
# Should NOT have namespace blocks
|
|
534
|
+
refute result.include?("namespace"), "cluster: false should not create namespace blocks"
|
|
535
|
+
# Should use full entity names
|
|
536
|
+
assert result.include?("class `Admin::Author`"), "Should use full entity name without clustering"
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
test "classDiagram with cluster and inheritance should emit entities before specializations" do
|
|
540
|
+
create_model "Vehicle", :type => :string
|
|
541
|
+
create_module_model "Transport::Car", Vehicle
|
|
542
|
+
|
|
543
|
+
result = diagram(:cluster => true, :inheritance => true).graph.join("\n")
|
|
544
|
+
|
|
545
|
+
# Entity class definitions should appear BEFORE specialization lines
|
|
546
|
+
vehicle_class_index = result.index("class `Vehicle`")
|
|
547
|
+
polymorphic_index = result.index("<<polymorphic>>")
|
|
548
|
+
inheritance_index = result.index("<|--")
|
|
549
|
+
|
|
550
|
+
assert vehicle_class_index, "Should have Vehicle class definition.\nGot:\n#{result}"
|
|
551
|
+
assert polymorphic_index, "Should have polymorphic marker.\nGot:\n#{result}"
|
|
552
|
+
assert inheritance_index, "Should have inheritance relationship.\nGot:\n#{result}"
|
|
553
|
+
|
|
554
|
+
# The key assertion: class definitions must come BEFORE specializations
|
|
555
|
+
assert vehicle_class_index < polymorphic_index,
|
|
556
|
+
"Class definitions should appear before polymorphic markers.\n" \
|
|
557
|
+
"Got:\n#{result}"
|
|
558
|
+
assert vehicle_class_index < inheritance_index,
|
|
559
|
+
"Class definitions should appear before inheritance relationships.\n" \
|
|
560
|
+
"Got:\n#{result}"
|
|
561
|
+
end
|
|
440
562
|
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require File.expand_path("../test_helper", File.dirname(__FILE__))
|
|
4
|
+
require "rails_erd/diagram/tbls"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
class TblsTest < ActiveSupport::TestCase
|
|
8
|
+
def setup
|
|
9
|
+
RailsERD.options.warn = false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def diagram(options = {})
|
|
13
|
+
Diagram::Tbls.new(Domain.generate(options), options).tap(&:generate)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def payload(options = {})
|
|
17
|
+
diagram(options).send(:schema_payload)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def table(payload, name)
|
|
21
|
+
payload[:tables].find { |t| t[:name] == name }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def constraint(table, type)
|
|
25
|
+
table[:constraints].find { |c| c[:type] == type }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# File output ==============================================================
|
|
29
|
+
test "filename has json extension" do
|
|
30
|
+
create_simple_domain
|
|
31
|
+
result = Diagram::Tbls.create
|
|
32
|
+
assert result.end_with?(".json"), "Expected .json, got #{result}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
test "written file is valid JSON parseable by tbls schema shape" do
|
|
36
|
+
create_simple_domain
|
|
37
|
+
file = Diagram::Tbls.create
|
|
38
|
+
parsed = JSON.parse(File.read(file))
|
|
39
|
+
|
|
40
|
+
assert parsed.key?("tables")
|
|
41
|
+
assert parsed["tables"].is_a?(Array)
|
|
42
|
+
assert parsed["tables"].all? { |t| t.key?("name") && t.key?("columns") }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Tables ===================================================================
|
|
46
|
+
test "emits one table per concrete entity using db table name" do
|
|
47
|
+
create_simple_domain
|
|
48
|
+
p = payload
|
|
49
|
+
|
|
50
|
+
names = p[:tables].map { |t| t[:name] }.sort
|
|
51
|
+
assert_equal %w[bars beers], names
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
test "columns carry name, type, nullable; default and comment only when set" do
|
|
55
|
+
create_model "Widget", name: :string, qty: :integer do
|
|
56
|
+
belongs_to :gizmo, optional: true
|
|
57
|
+
end
|
|
58
|
+
create_model "Gizmo"
|
|
59
|
+
|
|
60
|
+
widgets = table(payload, "widgets")
|
|
61
|
+
name_col = widgets[:columns].find { |c| c[:name] == "name" }
|
|
62
|
+
|
|
63
|
+
assert name_col[:type].start_with?("varchar"), "Expected varchar type, got #{name_col[:type]}"
|
|
64
|
+
assert_equal true, name_col[:nullable]
|
|
65
|
+
# tbls schema rejects null comments/defaults — they must be omitted, not nulled.
|
|
66
|
+
refute_includes name_col.keys, :default
|
|
67
|
+
refute_includes name_col.keys, :comment
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Primary keys =============================================================
|
|
71
|
+
test "every table gets a PRIMARY KEY constraint" do
|
|
72
|
+
create_simple_domain
|
|
73
|
+
|
|
74
|
+
table(payload, "beers")[:constraints].tap do |constraints|
|
|
75
|
+
pk = constraints.find { |c| c[:type] == "PRIMARY KEY" }
|
|
76
|
+
assert pk, "expected a PRIMARY KEY constraint"
|
|
77
|
+
assert_equal ["id"], pk[:columns]
|
|
78
|
+
assert_equal "beers_pkey", pk[:name]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Foreign keys =============================================================
|
|
83
|
+
test "belongs_to becomes a FOREIGN KEY constraint on the owning table" do
|
|
84
|
+
create_simple_domain # Beer belongs_to :bar
|
|
85
|
+
|
|
86
|
+
beers = table(payload, "beers")
|
|
87
|
+
fk = constraint(beers, "FOREIGN KEY")
|
|
88
|
+
|
|
89
|
+
assert fk, "expected a FOREIGN KEY constraint on beers"
|
|
90
|
+
assert_equal ["bar_id"], fk[:columns]
|
|
91
|
+
assert_equal "bars", fk[:referenced_table]
|
|
92
|
+
assert_equal ["id"], fk[:referenced_columns]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
test "FK def string is parseable by tbls parser" do
|
|
96
|
+
create_simple_domain
|
|
97
|
+
fk = constraint(table(payload, "beers"), "FOREIGN KEY")
|
|
98
|
+
|
|
99
|
+
assert_equal "FOREIGN KEY (bar_id) REFERENCES bars(id)", fk[:def]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
test "has_many side does not get an FK; only the belongs_to side does" do
|
|
103
|
+
create_one_to_many_assoc_domain # One has_many :many; Many belongs_to :one
|
|
104
|
+
|
|
105
|
+
ones = table(payload, "ones")
|
|
106
|
+
manies = table(payload, "manies")
|
|
107
|
+
|
|
108
|
+
assert_nil constraint(ones, "FOREIGN KEY"), "owner side must not have an FK"
|
|
109
|
+
assert constraint(manies, "FOREIGN KEY"), "belongs_to side must have an FK"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
test "polymorphic associations are skipped (no synthetic FK)" do
|
|
113
|
+
create_polymorphic_generalization # Cannon belongs_to :defensible, polymorphic; Galleon has_many :cannons, as: :defensible
|
|
114
|
+
|
|
115
|
+
cannons = table(payload(polymorphism: true), "cannons")
|
|
116
|
+
assert_nil constraint(cannons, "FOREIGN KEY"),
|
|
117
|
+
"polymorphic belongs_to has no concrete target — must not emit FK"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
test "indirect (has_many :through) relationships do not produce extra constraints" do
|
|
121
|
+
create_model "Author" do
|
|
122
|
+
has_many :authorships
|
|
123
|
+
has_many :books, through: :authorships
|
|
124
|
+
end
|
|
125
|
+
create_model "Book" do
|
|
126
|
+
has_many :authorships
|
|
127
|
+
has_many :authors, through: :authorships
|
|
128
|
+
end
|
|
129
|
+
create_model "Authorship", author: :references, book: :references do
|
|
130
|
+
belongs_to :author
|
|
131
|
+
belongs_to :book
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
authorships = table(payload, "authorships")
|
|
135
|
+
fk_targets = authorships[:constraints]
|
|
136
|
+
.select { |c| c[:type] == "FOREIGN KEY" }
|
|
137
|
+
.map { |c| c[:referenced_table] }
|
|
138
|
+
.sort
|
|
139
|
+
|
|
140
|
+
# Exactly two FKs — author + book — not duplicated by the indirect relationship.
|
|
141
|
+
assert_equal %w[authors books], fk_targets
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Schema-level payload =====================================================
|
|
145
|
+
test "top-level payload has a name and a tables array" do
|
|
146
|
+
create_simple_domain
|
|
147
|
+
p = payload
|
|
148
|
+
|
|
149
|
+
assert p[:name].is_a?(String) && !p[:name].empty?
|
|
150
|
+
assert p[:tables].is_a?(Array)
|
|
151
|
+
end
|
|
152
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-erd
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0
|
|
4
|
+
version: 2.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rolf Timmermans
|
|
@@ -118,6 +118,8 @@ executables:
|
|
|
118
118
|
extensions: []
|
|
119
119
|
extra_rdoc_files: []
|
|
120
120
|
files:
|
|
121
|
+
- CHANGES.md
|
|
122
|
+
- LICENSE.md
|
|
121
123
|
- README.md
|
|
122
124
|
- Rakefile
|
|
123
125
|
- bin/erd
|
|
@@ -131,6 +133,7 @@ files:
|
|
|
131
133
|
- lib/rails_erd/diagram.rb
|
|
132
134
|
- lib/rails_erd/diagram/graphviz.rb
|
|
133
135
|
- lib/rails_erd/diagram/mermaid.rb
|
|
136
|
+
- lib/rails_erd/diagram/tbls.rb
|
|
134
137
|
- lib/rails_erd/diagram/templates/node.html.erb
|
|
135
138
|
- lib/rails_erd/diagram/templates/node.record.erb
|
|
136
139
|
- lib/rails_erd/domain.rb
|
|
@@ -160,6 +163,7 @@ files:
|
|
|
160
163
|
- test/unit/rake_task_test.rb
|
|
161
164
|
- test/unit/relationship_test.rb
|
|
162
165
|
- test/unit/specialization_test.rb
|
|
166
|
+
- test/unit/tbls_test.rb
|
|
163
167
|
homepage: https://github.com/voormedia/rails-erd
|
|
164
168
|
licenses:
|
|
165
169
|
- MIT
|
|
@@ -199,3 +203,4 @@ test_files:
|
|
|
199
203
|
- test/unit/rake_task_test.rb
|
|
200
204
|
- test/unit/relationship_test.rb
|
|
201
205
|
- test/unit/specialization_test.rb
|
|
206
|
+
- test/unit/tbls_test.rb
|