activerecord-erd 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 23a0f8d12a11c8f049ed894e5a622c750b7c9045651374ddbb756a107e0bb277
4
+ data.tar.gz: 42ac36efbd4778b8bf37b7007d396b431a82efe6930e82b377489c77cd168fb6
5
+ SHA512:
6
+ metadata.gz: 807ef84e86a6a1a30deec9a82719d1dd4ba3c3c74a26edf3dbf9a56b9f0fa576c4b800d4f475e9aa1131020029fbb85e72581fa3ccf86bede1cf75742a190d2c
7
+ data.tar.gz: d1aed11a20dff4480dacf356b0002e1fb88a7ce74859462bad37cd8e31a16630f5b1f18d63c3a338f2e543bb3d7da1f5eae21d06e7375418e80284cd80ddc0af
@@ -0,0 +1,9 @@
1
+ module ActiveRecordERD
2
+ class Railtie < Rails::Railtie
3
+ initializer "activerecord-erd.initialize" do
4
+ ActiveSupport.on_load(:active_record) do
5
+ ActiveRecord::Base.include(ActiveRecordERD::ERDGenerator)
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,3 @@
1
+ module ActiveRecordERD
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,713 @@
1
+ require "activerecord-erd/version"
2
+ require "active_record"
3
+
4
+ module ActiveRecordERD
5
+ module ERDGenerator
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ base.include(InstanceMethods)
9
+ end
10
+
11
+ module ClassMethods
12
+ def erd(empty: false)
13
+ generate_erd(:class, nil, empty)
14
+ end
15
+
16
+ def erd_for_instance(instance, empty: false)
17
+ generate_erd(:instance, instance, empty)
18
+ end
19
+
20
+ private
21
+
22
+ # Shared method for generating ERD diagrams
23
+ def generate_erd(scope, instance = nil, empty = false)
24
+ # Get all foreign key constraints for this model
25
+ foreign_keys = connection.foreign_keys(table_name)
26
+
27
+ # Get all associations that have dependent: :destroy
28
+ dependent_associations = reflect_on_all_associations.select do |assoc|
29
+ assoc.options[:dependent] == :destroy
30
+ end
31
+
32
+ # Log all dependent associations
33
+ Rails.logger.debug("Model #{name} has dependent associations: #{dependent_associations.map(&:name).join(', ')}")
34
+
35
+ # Build the ERD diagram
36
+ diagram = ["erDiagram"]
37
+
38
+ # Track entity counts for boxes
39
+ entity_counts = {}
40
+
41
+ # Track dependent counts for each model
42
+ dependent_counts = {}
43
+
44
+ # Track entity metadata for entity boxes
45
+ entity_metadata = {}
46
+
47
+ # Map of models to show in the diagram
48
+ models_to_show = {}
49
+
50
+ # Track which models are simple vs complex dependencies
51
+ simple_dependencies = {}
52
+ complex_dependencies = {}
53
+
54
+ # Track entities that only have belongs_to relationships with the primary entity
55
+ belongs_to_only_entities = {}
56
+
57
+ # Count all dependents - for class or instance scope
58
+ dependent_associations.each do |assoc|
59
+ if scope == :class
60
+ count_dependents(self, assoc, dependent_counts)
61
+ else
62
+ count_dependents(instance, assoc, dependent_counts)
63
+ end
64
+ end
65
+
66
+ # Debug which dependent associations exist
67
+ Rails.logger.debug("ERD for #{name} - dependent associations: #{dependent_associations.map(&:name).inspect}")
68
+
69
+ # Define entity boxes with counts
70
+ if scope == :class
71
+ # For class level, count total records
72
+ total_count = count
73
+ entity_counts[name] = total_count
74
+ else
75
+ # For instance level, just show 1 for the current instance
76
+ entity_counts[name] = 1
77
+ end
78
+
79
+ # Initialize metadata for current entity
80
+ entity_metadata[name] = {
81
+ foreign_keys: [],
82
+ dependent_assocs: [],
83
+ through_assocs: [],
84
+ complex_assocs: []
85
+ }
86
+
87
+ # Add counts to dependent entities
88
+ dependent_associations.each do |assoc|
89
+ target_class = assoc.klass
90
+ next unless target_class < ActiveRecord::Base
91
+
92
+ count = dependent_counts[target_class.name] || 0
93
+ entity_counts[target_class.name] = count
94
+
95
+ # Mark this model to be shown
96
+ models_to_show[target_class.name] = true
97
+
98
+ # Initialize metadata if needed
99
+ entity_metadata[target_class.name] ||= {
100
+ foreign_keys: [],
101
+ dependent_assocs: [],
102
+ through_assocs: [],
103
+ complex_assocs: []
104
+ }
105
+
106
+ # Add info about this dependent association
107
+ entity_metadata[target_class.name][:dependent_assocs] << "#{assoc.foreign_key || 'unknown'} -> #{name} (#{assoc.name})"
108
+ end
109
+
110
+ # Process all has_many and has_one associations
111
+ reflect_on_all_associations.each do |assoc|
112
+ next if assoc.polymorphic?
113
+
114
+ begin
115
+ target_class = assoc.klass
116
+ next unless target_class < ActiveRecord::Base
117
+
118
+ # Calculate count for the association
119
+ count = 0
120
+ if scope == :instance
121
+ begin
122
+ if assoc.macro == :has_many || assoc.macro == :has_one
123
+ # For has_many, call count on the collection
124
+ # For has_one, check if present (1) or not (0)
125
+ if assoc.macro == :has_many
126
+ count = instance.send(assoc.name).count
127
+ else
128
+ # For has_one, just check if it exists
129
+ count = instance.send(assoc.name).present? ? 1 : 0
130
+ end
131
+ elsif assoc.macro == :belongs_to
132
+ # For belongs_to, check if present (1) or not (0)
133
+ count = instance.send(assoc.name).present? ? 1 : 0
134
+
135
+ # Immediately mark this as a belongs_to only entity unless already marked otherwise
136
+ unless complex_dependencies[target_class.name] || simple_dependencies[target_class.name]
137
+ belongs_to_only_entities[target_class.name] = true
138
+ end
139
+ end
140
+ rescue => e
141
+ # For errors (undefined method 'count' for nil or object), log and continue
142
+ Rails.logger.error("Error counting #{assoc.name} association: #{e.message}")
143
+ # If error was trying to call count on nil, set count to 0
144
+ count = 0
145
+ end
146
+ else
147
+ # For class level, we'll just use general counts
148
+ count = target_class.count
149
+
150
+ # If this is a belongs_to association, mark it as belongs_to only unless already marked otherwise
151
+ if assoc.macro == :belongs_to
152
+ unless complex_dependencies[target_class.name] || simple_dependencies[target_class.name]
153
+ belongs_to_only_entities[target_class.name] = true
154
+ end
155
+ end
156
+ end
157
+
158
+ entity_counts[target_class.name] = count
159
+
160
+ # Initialize metadata if needed
161
+ entity_metadata[target_class.name] ||= {
162
+ foreign_keys: [],
163
+ dependent_assocs: [],
164
+ through_assocs: [],
165
+ complex_assocs: []
166
+ }
167
+
168
+ # Mark this model to be shown if it has records or if it's a direct has_many/has_one relationship
169
+ # This ensures that even empty has_many relationships will show up in the diagram
170
+ if count > 0 || empty || (assoc.macro == :has_many && assoc.options[:dependent] == :destroy)
171
+ models_to_show[target_class.name] = true
172
+ end
173
+
174
+ # Handle special association types
175
+ if assoc.options[:through]
176
+ # Handle has_many :through associations
177
+ through_assoc = reflect_on_association(assoc.options[:through])
178
+ next unless through_assoc
179
+
180
+ source_assoc = reflect_on_association(through_assoc.options[:source] || through_assoc.name)
181
+ next unless source_assoc
182
+
183
+ join_table = through_assoc.table_name
184
+ join_fk = assoc.foreign_key
185
+
186
+ # Add through association metadata
187
+ entity_metadata[target_class.name][:through_assocs] << "via #{join_table}.#{join_fk} (#{assoc.name})"
188
+
189
+ # Mark as complex dependency
190
+ complex_dependencies[target_class.name] = true
191
+ # Ensure we don't treat this as a belongs_to only entity
192
+ belongs_to_only_entities.delete(target_class.name)
193
+ # Always show through associations in the diagram
194
+ models_to_show[target_class.name] = true
195
+ elsif assoc.scope.present? && (assoc.macro == :has_many || assoc.macro == :has_one)
196
+ # Handle complex join
197
+ entity_metadata[target_class.name][:complex_assocs] << "custom join (#{assoc.name})"
198
+
199
+ # Mark as complex dependency
200
+ complex_dependencies[target_class.name] = true
201
+ # Ensure we don't treat this as a belongs_to only entity
202
+ belongs_to_only_entities.delete(target_class.name)
203
+ # Always show complex associations in the diagram
204
+ models_to_show[target_class.name] = true
205
+ elsif assoc.macro == :belongs_to
206
+ # Skip belongs_to relationships for the primary entity
207
+ # as they don't prevent removal of the entity
208
+ unless name == target_class.name
209
+ # For secondary entities, still track foreign key info
210
+ entity_metadata[target_class.name][:foreign_keys] << "#{assoc.foreign_key} -> #{target_class.name}"
211
+
212
+ # Mark this entity as belongs_to only, unless it already has other relationships
213
+ unless complex_dependencies[target_class.name] || simple_dependencies[target_class.name]
214
+ belongs_to_only_entities[target_class.name] = true
215
+ end
216
+ end
217
+ else
218
+ # For simpler has_many/has_one, add dependency info to target
219
+ fk_column = "#{model_name.singular}_id" # Default guess
220
+
221
+ # Try to find the actual foreign key if possible
222
+ target_reflection = target_class.reflect_on_all_associations(:belongs_to).find do |r|
223
+ !r.polymorphic? && r.klass == self
224
+ end
225
+
226
+ fk_column = target_reflection.foreign_key if target_reflection
227
+
228
+ if assoc.options[:dependent] == :destroy
229
+ # This has a dependent: :destroy option, mark it for inclusion in the diagram
230
+ models_to_show[target_class.name] = true
231
+
232
+ # Check if this is a simple dependency
233
+ if !complex_dependencies[target_class.name] &&
234
+ target_class.reflect_on_all_associations.count { |a| a.options[:dependent] == :destroy } == 0
235
+ # This is a simple dependency - it has no further dependents
236
+ simple_dependencies[target_class.name] = true
237
+ # This entity has a relationship beyond belongs_to
238
+ belongs_to_only_entities.delete(target_class.name)
239
+ else
240
+ # This entity has its own dependents or is involved in complex relations
241
+ complex_dependencies[target_class.name] = true
242
+ simple_dependencies.delete(target_class.name)
243
+ # This entity has a relationship beyond belongs_to
244
+ belongs_to_only_entities.delete(target_class.name)
245
+ end
246
+ else
247
+ # Non-dependent association
248
+ entity_metadata[target_class.name][:dependent_assocs] << "#{fk_column} -> #{name} (#{assoc.name})"
249
+ # This entity has a relationship beyond belongs_to
250
+ belongs_to_only_entities.delete(target_class.name)
251
+ end
252
+ end
253
+ rescue => e
254
+ Rails.logger.error("Error processing association #{assoc.name}: #{e.message}")
255
+ end
256
+ end
257
+
258
+ # Add foreign key constraint info
259
+ foreign_keys.each do |fk|
260
+ referenced_table = fk.to_table
261
+ referenced_model = referenced_table.classify.constantize rescue nil
262
+ next unless referenced_model && referenced_model < ActiveRecord::Base
263
+
264
+ # Skip foreign keys for the primary entity for belongs_to relationships
265
+ # that don't prevent removal of the entity
266
+ if reflect_on_all_associations(:belongs_to).any? do |assoc|
267
+ assoc.foreign_key == fk.column && assoc.klass.name == referenced_model.name
268
+ end
269
+ # If we found a belongs_to relationship, mark it as belongs_to only and skip
270
+ belongs_to_only_entities[referenced_model.name] = true
271
+ models_to_show.delete(referenced_model.name)
272
+
273
+ Rails.logger.debug("Skipping foreign key #{fk.column} -> #{referenced_model.name} as it's a belongs_to relationship that doesn't prevent entity removal")
274
+ next
275
+ end
276
+
277
+ # Get count for the referenced model
278
+ fk_count = 0
279
+ if scope == :instance
280
+ begin
281
+ # For instance-level, check if the foreign key is non-null
282
+ fk_value = instance.send(fk.column)
283
+ if fk_value
284
+ # If FK has a value, count is 1 (the reference exists)
285
+ fk_count = 1
286
+ end
287
+ rescue => e
288
+ Rails.logger.error("Error checking foreign key #{fk.column}: #{e.message}")
289
+ end
290
+ else
291
+ # For class-level, count non-null foreign keys
292
+ begin
293
+ fk_count = where.not(fk.column => nil).count
294
+ rescue => e
295
+ Rails.logger.error("Error counting foreign key #{fk.column}: #{e.message}")
296
+ end
297
+ end
298
+
299
+ # Track foreign key data with count
300
+ entity_metadata[name][:foreign_keys] << "#{fk.column} -> #{referenced_model.name} (#{fk_count})"
301
+
302
+ # Update entity count for the referenced model
303
+ if fk_count > 0
304
+ entity_counts[referenced_model.name] ||= 0
305
+ entity_counts[referenced_model.name] += fk_count
306
+ end
307
+
308
+ # Mark the model to be shown if it has records or empty is true
309
+ if entity_counts[referenced_model.name].to_i > 0 || empty
310
+ models_to_show[referenced_model.name] = true
311
+ end
312
+
313
+ # Mark as complex dependency because it's referenced by a foreign key
314
+ complex_dependencies[referenced_model.name] = true
315
+ # This entity has a relationship beyond belongs_to
316
+ belongs_to_only_entities.delete(referenced_model.name)
317
+ end
318
+
319
+ # Add dependent association info
320
+ dependent_associations.each do |assoc|
321
+ target_class = assoc.klass
322
+ next unless target_class < ActiveRecord::Base
323
+
324
+ # Get the actual foreign key column name
325
+ fk_column = if assoc.macro == :belongs_to
326
+ assoc.foreign_key
327
+ elsif assoc.polymorphic?
328
+ "polymorphic"
329
+ else
330
+ # For has_many/has_one, the foreign key is on the other model
331
+ target_reflection = nil
332
+ begin
333
+ target_reflection = target_class.reflect_on_all_associations(:belongs_to).find do |r|
334
+ !r.polymorphic? && r.klass == self
335
+ end
336
+ rescue => e
337
+ # If we can't find a reflection, just use a default
338
+ Rails.logger.error("Error finding reflection: #{e.message}")
339
+ end
340
+ target_reflection&.foreign_key || "#{model_name.singular}_id"
341
+ end
342
+
343
+ # Track dependent association data for target class
344
+ entity_metadata[target_class.name][:dependent_assocs] << "#{fk_column} -> #{name} (#{assoc.name})"
345
+
346
+ # Check if this is a simple dependency (has no further dependents)
347
+ if target_class.reflect_on_all_associations.none? { |a| a.options[:dependent] == :destroy }
348
+ simple_dependencies[target_class.name] = true
349
+ else
350
+ complex_dependencies[target_class.name] = true
351
+ simple_dependencies.delete(target_class.name)
352
+ end
353
+ end
354
+
355
+ # Always include the current model
356
+ models_to_show[name] = true
357
+ complex_dependencies[name] = true # Root entity is always complex
358
+
359
+ # Any model that has a foreign key or is referenced by a complex model is also complex
360
+ entity_metadata.each do |entity_name, metadata|
361
+ if metadata[:foreign_keys].any? || metadata[:through_assocs].any? || metadata[:complex_assocs].any?
362
+ complex_dependencies[entity_name] = true
363
+ simple_dependencies.delete(entity_name)
364
+ # This entity has a relationship beyond belongs_to
365
+ belongs_to_only_entities.delete(entity_name)
366
+ end
367
+ end
368
+
369
+ # Final check to ensure belongs_to associations are completely excluded
370
+ # Check all belongs_to associations and ensure they're fully excluded
371
+ reflect_on_all_associations(:belongs_to).each do |assoc|
372
+ next if assoc.polymorphic?
373
+
374
+ begin
375
+ target_class = assoc.klass
376
+ next unless target_class < ActiveRecord::Base
377
+
378
+ # Skip self-references
379
+ next if target_class.name == name
380
+
381
+ # IMPORTANT FIX: Skip if there is a dependent: :destroy association going back to this model
382
+ # This prevents removing models like Build that have a has_many relationship from App
383
+ if dependent_associations.any? { |da| da.klass == target_class }
384
+ # For models that are both a belongs_to target and have a dependent: :destroy relationship,
385
+ # ensure they're properly added to simple_dependencies
386
+ simple_dependencies[target_class.name] = true
387
+ models_to_show[target_class.name] = true
388
+ # Also remove from belongs_to_only_entities
389
+ belongs_to_only_entities.delete(target_class.name)
390
+ Rails.logger.debug("Keeping #{target_class.name} as it has a dependent: :destroy relationship with #{name}")
391
+ # Add to diagram as a comment for debugging
392
+ diagram << " %% Ensuring #{target_class.name} stays in the SimpleDependencies"
393
+ next
394
+ end
395
+
396
+ # Remove from models_to_show and mark as belongs_to only
397
+ belongs_to_only_entities[target_class.name] = true
398
+ models_to_show.delete(target_class.name)
399
+ complex_dependencies.delete(target_class.name)
400
+ simple_dependencies.delete(target_class.name)
401
+
402
+ Rails.logger.debug("Final check - removing #{target_class.name} as it's a belongs_to relationship with #{name}")
403
+ rescue => e
404
+ Rails.logger.error("Error in final belongs_to check for #{assoc.name}: #{e.message}")
405
+ end
406
+ end
407
+
408
+ # Before processing relationships, ensure belongs_to only entities are not shown
409
+ belongs_to_only_entities.each_key do |entity_name|
410
+ models_to_show.delete(entity_name)
411
+ end
412
+
413
+ # Add a subgraph for simple dependencies if any exist
414
+ if simple_dependencies.keys.any?
415
+ diagram << " %% Simple Dependencies Subgraph"
416
+
417
+ # Create a single entity for all simple dependencies
418
+ valid_simple_deps = simple_dependencies.keys.select do |entity_name|
419
+ models_to_show[entity_name] && (entity_counts[entity_name].to_i > 0 || empty)
420
+ end.sort
421
+
422
+ if valid_simple_deps.any?
423
+ # Create a single entity box containing all simple dependencies
424
+ diagram << " SimpleDependencies {"
425
+ diagram << " title string \"Simple Dependencies\""
426
+
427
+ # Add each simple dependency as a property
428
+ valid_simple_deps.each_with_index do |entity_name, idx|
429
+ count = entity_counts[entity_name] || 0
430
+ diagram << " dep#{idx+1} string \"#{entity_name} (#{count})\""
431
+ end
432
+
433
+ diagram << " }"
434
+
435
+ # Add relationship lines from the main model to SimpleDependencies
436
+ diagram << " #{name} ||--o{ SimpleDependencies : \"has simple dependencies\""
437
+ end
438
+ end
439
+
440
+ # Complex dependencies section (without subgraph)
441
+ diagram << " %% Complex Dependencies"
442
+
443
+ # Define entity boxes for complex dependencies
444
+ complex_dependencies.keys.sort.each do |entity_name|
445
+ next unless models_to_show[entity_name]
446
+ count = entity_counts[entity_name] || 0
447
+
448
+ # Skip if count is 0 and we're not showing empty relationships
449
+ next if !empty && count == 0
450
+
451
+ metadata = entity_metadata[entity_name] || {}
452
+
453
+ # Build entity box with metadata
454
+ entity_box = [" #{entity_name} {"]
455
+ entity_box << " count integer \"#{count}\""
456
+
457
+ # Add foreign keys section if present
458
+ if metadata[:foreign_keys]&.any?
459
+ metadata[:foreign_keys].each_with_index do |fk_info, idx|
460
+ entity_box << " fk#{idx+1} string \"#{fk_info}\""
461
+ end
462
+ end
463
+
464
+ # Add dependent associations section if present
465
+ if metadata[:dependent_assocs]&.any?
466
+ metadata[:dependent_assocs].each_with_index do |dep_info, idx|
467
+ entity_box << " dep#{idx+1} string \"#{dep_info}\""
468
+ end
469
+ end
470
+
471
+ # Add through associations section if present
472
+ if metadata[:through_assocs]&.any?
473
+ metadata[:through_assocs].each_with_index do |through_info, idx|
474
+ entity_box << " through#{idx+1} string \"#{through_info}\""
475
+ end
476
+ end
477
+
478
+ # Add complex associations section if present
479
+ if metadata[:complex_assocs]&.any?
480
+ metadata[:complex_assocs].each_with_index do |complex_info, idx|
481
+ entity_box << " complex#{idx+1} string \"#{complex_info}\""
482
+ end
483
+ end
484
+
485
+ entity_box << " }"
486
+ diagram << entity_box.join("\n")
487
+ end
488
+
489
+ # Add relationship lines
490
+
491
+ # Add foreign key relationships with simplified labels
492
+ foreign_keys.each do |fk|
493
+ referenced_table = fk.to_table
494
+ referenced_model = referenced_table.classify.constantize rescue nil
495
+ next unless referenced_model && referenced_model < ActiveRecord::Base
496
+
497
+ # Skip if model should not be shown or is a belongs_to only entity
498
+ next unless models_to_show[referenced_model.name]
499
+ next if belongs_to_only_entities[referenced_model.name]
500
+
501
+ # Add the relationship with simplified label
502
+ diagram << " #{referenced_model.name} ||--o{ #{name} : references"
503
+ end
504
+
505
+ # Add dependent: :destroy relationships with simplified labels
506
+ dependent_associations.each do |assoc|
507
+ target_class = assoc.klass
508
+ next unless target_class < ActiveRecord::Base
509
+
510
+ # Skip if model should not be shown or is a belongs_to only entity
511
+ next unless models_to_show[target_class.name]
512
+ next if belongs_to_only_entities[target_class.name]
513
+
514
+ # Skip if this is a simple dependency - it's now represented by the SimpleDependencies entity
515
+ next if simple_dependencies[target_class.name]
516
+
517
+ # Skip belongs_to relationships for the primary entity
518
+ next if assoc.macro == :belongs_to && target_class.name != name
519
+
520
+ # Add the relationship with a label that includes dependent: :destroy
521
+ diagram << " #{name} ||--o{ #{target_class.name} : \"dependent: :destroy (#{assoc.name})\""
522
+ end
523
+
524
+ # Add has_many/has_one/belongs_to relationships not covered by above
525
+ reflect_on_all_associations.each do |assoc|
526
+ next if assoc.polymorphic?
527
+ next if assoc.options[:dependent] == :destroy # Already handled
528
+
529
+ begin
530
+ target_class = assoc.klass
531
+ next unless target_class < ActiveRecord::Base
532
+
533
+ # Skip if model should not be shown or is a belongs_to only entity
534
+ next unless models_to_show[target_class.name]
535
+ next if belongs_to_only_entities[target_class.name]
536
+
537
+ # Skip if this is a simple dependency - it's now represented by the SimpleDependencies entity
538
+ next if simple_dependencies[target_class.name]
539
+
540
+ # Skip self-references
541
+ next if target_class.name == name
542
+
543
+ # Skip belongs_to relationships for the primary entity
544
+ next if assoc.macro == :belongs_to && target_class.name != name
545
+
546
+ # Handle various association types
547
+ if assoc.options[:through]
548
+ # Add has_many through relationship
549
+ through_assoc = reflect_on_association(assoc.options[:through])
550
+ next unless through_assoc
551
+
552
+ source_assoc = reflect_on_association(through_assoc.options[:source] || through_assoc.name)
553
+ next unless source_assoc
554
+
555
+ diagram << " #{name} ||--o{ #{target_class.name} : \"has_many through (#{assoc.name})\""
556
+ elsif assoc.scope.present? && (assoc.macro == :has_many || assoc.macro == :has_one)
557
+ # Add complex scoped association
558
+ diagram << " #{name} ||--o{ #{target_class.name} : \"complex join (#{assoc.name})\""
559
+ elsif assoc.macro == :belongs_to
560
+ # Add belongs_to (reverse the arrow direction compared to has_many)
561
+ diagram << " #{target_class.name} ||--o{ #{name} : \"belongs_to (#{assoc.name})\""
562
+ elsif assoc.macro == :has_many
563
+ # Add standard has_many
564
+ diagram << " #{name} ||--o{ #{target_class.name} : \"has_many (#{assoc.name})\""
565
+ elsif assoc.macro == :has_one
566
+ # Add has_one
567
+ diagram << " #{name} ||--|| #{target_class.name} : \"has_one (#{assoc.name})\""
568
+ end
569
+ rescue => e
570
+ Rails.logger.error("Error adding relationship for #{assoc.name}: #{e.message}")
571
+ end
572
+ end
573
+
574
+ # Return the diagram as a string without mermaid prefix/suffix
575
+ diagram.join("\n")
576
+ end
577
+
578
+ # Helper method to count dependents recursively
579
+ def count_dependents(model, association, counts)
580
+ target_class = association.klass
581
+ return unless target_class < ActiveRecord::Base
582
+
583
+ # Count direct dependents using the association's foreign key
584
+ begin
585
+ count = 0
586
+ if model.is_a?(Class)
587
+ # Class-level counting (all records)
588
+ if association.macro == :has_many || association.macro == :has_one
589
+ # For has_many/has_one, the foreign key is on the target model
590
+ # Try to determine the correct foreign key
591
+ target_reflection = nil
592
+ begin
593
+ target_reflection = target_class.reflect_on_all_associations(:belongs_to).find do |r|
594
+ !r.polymorphic? && r.klass == self
595
+ end
596
+ rescue => e
597
+ Rails.logger.error("Error finding reflection: #{e.message}")
598
+ end
599
+
600
+ foreign_key = if target_reflection
601
+ target_reflection.foreign_key
602
+ elsif association.respond_to?(:foreign_key) && target_class.column_names.include?(association.foreign_key.to_s)
603
+ association.foreign_key
604
+ else
605
+ "#{model_name.singular}_id"
606
+ end
607
+
608
+ # Only proceed if the foreign key column actually exists
609
+ if target_class.column_names.include?(foreign_key.to_s)
610
+ count = target_class.where(foreign_key => model.pluck(:id)).count
611
+ else
612
+ Rails.logger.error("Foreign key column '#{foreign_key}' not found in '#{target_class.name}' table")
613
+ count = 0
614
+ end
615
+ elsif association.macro == :belongs_to
616
+ # For belongs_to, the foreign key is on this model
617
+ foreign_key = association.foreign_key
618
+ count = target_class.where(id: model.where.not(foreign_key => nil).select(foreign_key)).count
619
+ end
620
+ else
621
+ # Instance-level counting (single record)
622
+ if association.macro == :has_many || association.macro == :has_one
623
+ # For has_many/has_one relationships
624
+ count = instance_count_dependents(model, association)
625
+ elsif association.macro == :belongs_to
626
+ # For belongs_to, check if the association exists
627
+ count = model.send(association.name).present? ? 1 : 0
628
+ end
629
+ end
630
+
631
+ # Add to counts
632
+ counts[target_class.name] ||= 0
633
+ counts[target_class.name] += count
634
+
635
+ # Recursively count dependents of dependents
636
+ target_class.reflect_on_all_associations.each do |dep_assoc|
637
+ next unless dep_assoc.options[:dependent] == :destroy
638
+ # Pass on a collection of target records for recursive counting
639
+ if model.is_a?(Class)
640
+ # For class-level recursion, use a simpler approach to avoid errors
641
+ begin
642
+ related_records = target_class.all.to_a
643
+ # Limit this to avoid performance issues
644
+ related_records = related_records.first(10)
645
+ rescue => e
646
+ Rails.logger.error("Error fetching related records: #{e.message}")
647
+ related_records = []
648
+ end
649
+ else
650
+ # For instance-level recursion, fetch the associated records
651
+ related_records = instance_fetch_associated_records(model, association) || []
652
+ end
653
+
654
+ # Count each related record's dependents
655
+ related_records.each do |record|
656
+ count_dependents(record, dep_assoc, counts)
657
+ end
658
+ end
659
+ rescue => e
660
+ # Log errors to help with debugging
661
+ Rails.logger.error("Error counting dependents for #{model.name || model.class.name} -> #{target_class.name}: #{e.message}")
662
+ end
663
+ end
664
+
665
+ # Helper method to count instance-level dependents
666
+ def instance_count_dependents(instance, association)
667
+ return 0 unless instance && association
668
+
669
+ begin
670
+ # Try to get the count directly from the association
671
+ if association.scope.present?
672
+ # For complex scoped associations, just evaluate the association
673
+ instance.send(association.name).count
674
+ else
675
+ # For standard associations, try to use the association's count
676
+ instance.send(association.name).count
677
+ end
678
+ rescue => e
679
+ Rails.logger.error("Error counting instance-level dependents for #{association.name}: #{e.message}")
680
+ 0
681
+ end
682
+ end
683
+
684
+ # Helper method to fetch associated records for an instance
685
+ def instance_fetch_associated_records(instance, association)
686
+ return [] unless instance && association
687
+
688
+ begin
689
+ # Try to get the associated records
690
+ if association.scope.present?
691
+ # For complex scoped associations, just evaluate the association
692
+ instance.send(association.name).to_a
693
+ else
694
+ # For standard associations, fetch the records
695
+ instance.send(association.name).to_a
696
+ end
697
+ rescue => e
698
+ Rails.logger.error("Error fetching associated records for #{association.name}: #{e.message}")
699
+ []
700
+ end
701
+ end
702
+ end
703
+
704
+ module InstanceMethods
705
+ def erd(empty: false)
706
+ self.class.erd_for_instance(self, empty: empty)
707
+ end
708
+ end
709
+ end
710
+ end
711
+
712
+ # Railtie to hook into Rails initialization
713
+ require "activerecord-erd/railtie" if defined?(Rails)
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord-erd
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jonathan
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-03-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: sqlite3
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.4'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.4'
83
+ description: Generate Mermaid-based ERD diagrams for ActiveRecord models with their
84
+ dependencies
85
+ email:
86
+ - email@no-spam-please.com
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - lib/activerecord-erd.rb
92
+ - lib/activerecord-erd/railtie.rb
93
+ - lib/activerecord-erd/version.rb
94
+ homepage: https://github.com/usiegj00/activerecord-erd
95
+ licenses:
96
+ - MIT
97
+ metadata:
98
+ homepage_uri: https://github.com/usiegj00/activerecord-erd
99
+ source_code_uri: https://github.com/usiegj00/activerecord-erd
100
+ post_install_message:
101
+ rdoc_options: []
102
+ require_paths:
103
+ - lib
104
+ required_ruby_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: 2.5.0
109
+ required_rubygems_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ requirements: []
115
+ rubygems_version: 3.5.16
116
+ signing_key:
117
+ specification_version: 4
118
+ summary: Entity Relationship Diagram generator for ActiveRecord models
119
+ test_files: []