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.
@@ -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
@@ -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
@@ -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
@@ -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.1
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