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.
@@ -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
@@ -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.2
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
@@ -178,7 +182,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
178
182
  - !ruby/object:Gem::Version
179
183
  version: '0'
180
184
  requirements: []
181
- rubygems_version: 4.0.13
185
+ rubygems_version: 4.0.10
182
186
  specification_version: 4
183
187
  summary: Entity-relationship diagram for your Rails models.
184
188
  test_files:
@@ -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