mermaid_rails_erd 1.0.1

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.
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "column_info"
4
+
5
+ module MermaidRailsErd
6
+ class ModelDataCollector
7
+ attr_reader :models_data, :tables, :models_no_tables, :models, :invalid_associations, :polymorphic_associations, :regular_associations
8
+
9
+ def initialize(model_loader)
10
+ @models_data = {}
11
+ @polymorphic_associations = []
12
+ @regular_associations = []
13
+ @invalid_associations = []
14
+ @polymorphic_targets = Hash.new { |h, k| h[k] = [] }
15
+ @tables = {}
16
+ @models_no_tables = []
17
+ @models = model_loader.load
18
+ end
19
+
20
+ def collect
21
+ (base_models & models_with_tables).each do |model|
22
+ model_data = { model: model, associations: [] }
23
+
24
+ # Register polymorphic interfaces that this model implements
25
+ model.reflect_on_all_associations.each do |assoc|
26
+ if (interface_name = assoc.options[:as])
27
+ register_polymorphic_target(interface_name, model)
28
+ end
29
+
30
+ if assoc.options[:polymorphic]
31
+ @polymorphic_associations << { model: model, association: assoc }
32
+ else
33
+ @regular_associations << { model: model, association: assoc }
34
+ end
35
+
36
+ model_data[:associations] << assoc
37
+ end
38
+
39
+ @models_data[model.name] = model_data
40
+
41
+ collect_table_for_model(model)
42
+ end
43
+
44
+ (base_models & models_without_tables).each do |model|
45
+ @models_no_tables << model
46
+ end
47
+
48
+ self
49
+ end
50
+
51
+ def get_model_data(model_name)
52
+ @models_data[model_name]
53
+ end
54
+
55
+ # Register a model as implementing a polymorphic interface
56
+ # @param interface_name [String, Symbol] Name of the polymorphic interface
57
+ # @param model [Class] Model that implements the interface
58
+ def register_polymorphic_target(interface_name, model)
59
+ @polymorphic_targets[interface_name.to_s] << model
60
+ end
61
+
62
+ # Returns all models that implement the given polymorphic interface
63
+ # @param polymorphic_name [String, Symbol] Name of the polymorphic interface
64
+ # @return [Array<Class>] Array of models that implement the interface
65
+ def polymorphic_targets_for(polymorphic_name)
66
+ @polymorphic_targets[polymorphic_name.to_s] || []
67
+ end
68
+
69
+ # Reset all registered polymorphic targets (useful for testing)
70
+ def reset_polymorphic_targets
71
+ @polymorphic_targets.clear
72
+ end
73
+
74
+ # Collect table information from a model
75
+ # @param model [Class] ActiveRecord model to collect table information from
76
+ def collect_table_for_model(model)
77
+ # Skip if we've already collected this table
78
+ return if @tables.key?(model.table_name)
79
+
80
+ # Collect basic table structure with columns
81
+ @tables[model.table_name] = model.columns.map do |col|
82
+ annotations = []
83
+ annotations << "PK" if col.name == model.primary_key
84
+
85
+ ColumnInfo.new(col.name, annotations, col.sql_type, col.type, col.null)
86
+ end
87
+ end
88
+
89
+ # Update table definitions with foreign key annotations
90
+ # @param relationships [Array<Relationship>] List of relationships to extract FKs from
91
+ # @return [Hash] Updated tables hash
92
+ def update_foreign_keys(relationships)
93
+ fk_mapping = extract_foreign_keys(relationships)
94
+
95
+ @tables.each do |table_name, columns|
96
+ next unless fk_mapping[table_name]
97
+
98
+ begin
99
+ columns.each do |col|
100
+ col.annotations << "FK" if fk_mapping[table_name].include?(col.name) && !col.annotations.include?("FK")
101
+ end
102
+ rescue StandardError => e
103
+ puts "Error updating FK annotations for #{table_name}: #{e.message}"
104
+ end
105
+ end
106
+
107
+ @tables
108
+ end
109
+
110
+ # Register an invalid association that couldn't be resolved
111
+ # @param model [Class] Model containing the invalid association
112
+ # @param assoc [ActiveRecord::Reflection::Association] Invalid association
113
+ # @param reason [String] Reason why the association is invalid
114
+ def register_invalid_association(model, assoc, reason = nil)
115
+ @invalid_associations << {
116
+ model: model,
117
+ association: assoc,
118
+ reason: reason,
119
+ label: "#{model.name}##{assoc.name}",
120
+ }
121
+ end
122
+
123
+ private
124
+
125
+ def base_models
126
+ models.reject { |model| (model < model.base_class) }
127
+ end
128
+
129
+ def models_with_tables
130
+ models.select(&:table_exists?)
131
+ end
132
+
133
+ def models_without_tables
134
+ models.reject(&:table_exists?)
135
+ end
136
+
137
+ # Extract foreign key information from relationships
138
+ # @param relationships [Array<Relationship>] List of relationships to extract FKs from
139
+ # @return [Hash] Mapping of table names to arrays of FK column names
140
+ def extract_foreign_keys(relationships)
141
+ fk_mapping = {}
142
+
143
+ relationships.each do |rel|
144
+ # Use the relationship attributes directly instead of parsing the label
145
+ table_name = rel.fk_table
146
+ fk_column = rel.fk_column
147
+
148
+ fk_mapping[table_name] ||= []
149
+ fk_mapping[table_name] << fk_column unless fk_mapping[table_name].include?(fk_column)
150
+ rescue StandardError => e
151
+ puts "Error extracting FK from relationship: #{e.message}"
152
+ end
153
+
154
+ fk_mapping
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MermaidRailsErd
4
+ class ModelLoader
5
+ def load
6
+ raise MermaidRailsErd::Error, "Rails is not loaded" unless defined?(Rails)
7
+
8
+ Rails.application.eager_load! unless Rails.configuration.cache_classes
9
+ ActiveRecord::Base.descendants.reject(&:abstract_class?)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MermaidRailsErd
4
+ # Class to hold parsed ERD data with delegation to model_data_collector
5
+ # Provides structured access to relationships, tables, and model data
6
+ class ParsedData
7
+ attr_reader :relationships, :tables, :model_data_collector
8
+
9
+ # Initialize with collected ERD data
10
+ # @param relationships [Array] Array of relationship objects
11
+ # @param tables [Hash] Hash of table definitions keyed by table name
12
+ # @param model_data_collector [ModelDataCollector] Collector instance with model data
13
+ def initialize(relationships, tables, model_data_collector)
14
+ @relationships = relationships
15
+ @tables = tables
16
+ @model_data_collector = model_data_collector
17
+ end
18
+
19
+ # Delegated methods from model_data_collector for better IDE support
20
+
21
+ # @return [Hash] Hash of model data keyed by model name
22
+ def models_data
23
+ model_data_collector.models_data
24
+ end
25
+
26
+ # @return [Array] Array of models without tables
27
+ def models_no_tables
28
+ model_data_collector.models_no_tables
29
+ end
30
+
31
+ # @return [Array] Array of all loaded models
32
+ def models
33
+ model_data_collector.models
34
+ end
35
+
36
+ # @return [Array<Hash>] Array of invalid associations with details
37
+ def invalid_associations
38
+ model_data_collector.invalid_associations
39
+ end
40
+
41
+ # @return [Array<Hash>] Array of polymorphic associations
42
+ def polymorphic_associations
43
+ model_data_collector.polymorphic_associations
44
+ end
45
+
46
+ # @return [Array<Hash>] Array of regular (non-polymorphic) associations
47
+ def regular_associations
48
+ model_data_collector.regular_associations
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "relationship"
4
+
5
+ module MermaidRailsErd
6
+ class PolymorphicTargetsResolver
7
+ attr_reader :model_data_collector
8
+
9
+ def initialize(model_data_collector)
10
+ @model_data_collector = model_data_collector
11
+ end
12
+
13
+ def resolve(name, from_table, rel_type)
14
+ # Get all models that implement the polymorphic interface
15
+ target_models = model_data_collector.polymorphic_targets_for(name)
16
+
17
+ # Create relationships for each target model
18
+ target_models.map do |target|
19
+ fk_column = "#{name}_id"
20
+ Relationship.new(
21
+ from_table,
22
+ target.table_name,
23
+ fk_column,
24
+ rel_type,
25
+ nil, # Let the Relationship generate the label
26
+ from_table, # fk_table
27
+ fk_column, # fk_column
28
+ target.table_name, # pk_table
29
+ "id", # pk_column
30
+ # Add (polymorphic) to the label
31
+ true, # is_polymorphic
32
+ "polymorphic", # extra_label
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MermaidRailsErd
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ load File.expand_path("../tasks/mermaid_rails_erd.rake", __dir__)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MermaidRailsErd
4
+ class Relationship
5
+ attr_reader :from_table, :to_table, :foreign_key, :relationship_type, :label, :fk_table, :fk_column, :pk_table, :pk_column, :is_polymorphic, :extra_label
6
+
7
+ # Provide aliases for backward compatibility
8
+ alias from from_table
9
+ alias to to_table
10
+ alias symbol relationship_type
11
+
12
+ def initialize(from, to, foreign_key, relationship_type, label = nil,
13
+ fk_table = nil, fk_column = nil, pk_table = nil, pk_column = nil,
14
+ is_polymorphic = false, extra_label = nil)
15
+ @from_table = from
16
+ @to_table = to
17
+ @foreign_key = foreign_key
18
+ @relationship_type = relationship_type
19
+ @is_polymorphic = is_polymorphic
20
+ @extra_label = extra_label
21
+
22
+ # Store FK/PK information
23
+ @fk_table = fk_table || from
24
+ @fk_column = fk_column || foreign_key
25
+ @pk_table = pk_table || to
26
+ @pk_column = pk_column || "id"
27
+
28
+ # Generate label if not provided
29
+ @label = label || generate_label
30
+ end
31
+
32
+ def key
33
+ [from_table, to_table, foreign_key].sort.join("::")
34
+ end
35
+
36
+ def generate_label
37
+ base_label = "#{@fk_table}.#{@fk_column} FK → #{@pk_table}.#{@pk_column} PK"
38
+ @is_polymorphic ? "#{base_label} (#{@extra_label})" : base_label
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require_relative "../relationship"
5
+
6
+ module MermaidRailsErd
7
+ module RelationshipBuilders
8
+ class BaseRelationshipBuilder
9
+ attr_reader :symbol_mapper, :association_resolver, :model_data_collector
10
+
11
+ # Class variable to track one-to-one relationships across builder instances
12
+ @@one_to_one_keys = Set.new
13
+
14
+ def initialize(symbol_mapper:, association_resolver:, model_data_collector: nil)
15
+ @symbol_mapper = symbol_mapper
16
+ @association_resolver = association_resolver
17
+ @model_data_collector = model_data_collector
18
+ end
19
+
20
+ def build(model, assoc)
21
+ raise NotImplementedError, "Subclasses must implement #build"
22
+ end
23
+
24
+ protected
25
+
26
+ def resolve_association_model(model, assoc)
27
+ # Let the association_resolver handle all the resolution logic
28
+ # including dynamic model creation when needed
29
+ association_resolver.resolve(assoc)
30
+ rescue StandardError => e
31
+ puts " ERROR resolving association class for #{model.name}##{assoc.name}: #{e.class} - #{e.message}"
32
+ puts e.backtrace.join("\n")
33
+ # Register invalid association if model_data_collector is available
34
+ register_invalid_association(model, assoc, "#{e.class} - #{e.message}")
35
+ nil
36
+ end
37
+
38
+ def safe_foreign_key(model, assoc)
39
+ # Skip through associations entirely
40
+ return nil if assoc.options[:through]
41
+
42
+ # Try to get the foreign key
43
+ begin
44
+ assoc.foreign_key
45
+ rescue StandardError => e
46
+ puts " WARNING: Cannot determine foreign key for #{model.name}##{assoc.name} - skipping"
47
+ register_invalid_association(model, assoc, "Cannot determine foreign key: #{e.class} - #{e.message}")
48
+ nil
49
+ end
50
+ end
51
+
52
+ def skip_duplicate_one_to_one?(model, assoc, to_table_info)
53
+ return false unless %i[has_one belongs_to].include?(assoc.macro)
54
+
55
+ # Skip the check for polymorphic associations
56
+ return false if assoc.options[:polymorphic]
57
+
58
+ return false unless to_table_info
59
+
60
+ # Create a unique key for this one-to-one relationship
61
+ rel_key = [model.table_name, to_table_info[:table_name], "1:1"].sort.join("::")
62
+
63
+ # Check if we've already processed this relationship
64
+ return true if @@one_to_one_keys.include?(rel_key)
65
+
66
+ # Mark this relationship as processed
67
+ @@one_to_one_keys << rel_key
68
+ false
69
+ end
70
+
71
+ def log_missing_table_warning(model, assoc, reason = "table does not exist")
72
+ if assoc.options[:class_name]
73
+ assoc.options[:class_name].to_s.tableize
74
+ else
75
+ assoc.name.to_s.tableize
76
+ end
77
+ puts " WARNING: Could not create relationship for #{model.name}##{assoc.name} - #{reason}"
78
+ register_invalid_association(model, assoc, reason)
79
+ []
80
+ end
81
+
82
+ private
83
+
84
+ def register_invalid_association(model, assoc, reason)
85
+ return unless @model_data_collector
86
+
87
+ @model_data_collector.register_invalid_association(model, assoc, reason)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_relationship_builder"
4
+
5
+ module MermaidRailsErd
6
+ module RelationshipBuilders
7
+ class BelongsToRelationshipBuilder < BaseRelationshipBuilder
8
+ def build(model, assoc)
9
+ from_table = model.table_name
10
+ fk = safe_foreign_key(model, assoc)
11
+
12
+ # Skip if we couldn't determine the foreign key
13
+ return [] unless fk
14
+
15
+ to_table_info = resolve_association_model(model, assoc)
16
+
17
+ # Skip if this is a duplicate one-to-one relationship
18
+ return [] if skip_duplicate_one_to_one?(model, assoc, to_table_info)
19
+
20
+ if to_table_info
21
+ # FK is on source table for belongs_to
22
+ [Relationship.new(
23
+ from_table,
24
+ to_table_info[:table_name],
25
+ fk,
26
+ "}o--||",
27
+ nil, # Let the Relationship generate the label
28
+ from_table, # fk_table
29
+ fk, # fk_column
30
+ to_table_info[:table_name], # pk_table
31
+ to_table_info[:primary_key], # pk_column
32
+ )]
33
+ else
34
+ log_missing_table_warning(model, assoc)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_relationship_builder"
4
+ require_relative "../relationship"
5
+
6
+ module MermaidRailsErd
7
+ module RelationshipBuilders
8
+ class HasAndBelongsToManyRelationshipBuilder < BaseRelationshipBuilder
9
+ def initialize(symbol_mapper:, association_resolver:, printed_tables: Set.new, model_data_collector: nil)
10
+ super(symbol_mapper: symbol_mapper, association_resolver: association_resolver, model_data_collector: model_data_collector)
11
+ @printed_tables = printed_tables
12
+ end
13
+
14
+ def build(model, assoc)
15
+ from_table = model.table_name
16
+ to_table_info = resolve_association_model(model, assoc)
17
+
18
+ return log_missing_table_warning(model, assoc, "target model does not exist") unless to_table_info
19
+
20
+ join_table = assoc.join_table.to_s
21
+
22
+ # Check if we need to verify the table existence
23
+ unless @printed_tables.include?(join_table)
24
+ begin
25
+ ActiveRecord::Base.connection.columns(join_table)
26
+ @printed_tables << join_table
27
+ rescue StandardError => e
28
+ return log_missing_table_warning(model, assoc, "join table #{join_table} is missing: #{e.message}")
29
+ end
30
+ end
31
+
32
+ # Try to get the foreign keys
33
+ source_fk = nil
34
+ target_fk = nil
35
+
36
+ begin
37
+ source_fk = assoc.foreign_key
38
+ rescue StandardError => e
39
+ puts " WARNING: Could not determine foreign key for #{model.name} in HABTM: #{e.message}"
40
+ register_invalid_association(model, assoc, "Could not determine foreign key: #{e.message}")
41
+ end
42
+
43
+ begin
44
+ target_fk = assoc.association_foreign_key
45
+ rescue StandardError => e
46
+ puts " WARNING: Could not determine association foreign key for #{model.name}##{assoc.name}: #{e.message}"
47
+ register_invalid_association(model, assoc, "Could not determine association foreign key: #{e.message}")
48
+ end
49
+
50
+ # Skip if we couldn't determine both foreign keys
51
+ return log_missing_table_warning(model, assoc, "could not determine foreign keys") if !source_fk || !target_fk
52
+
53
+ # If we reach here, the join table exists, so create relationships
54
+ [
55
+ Relationship.new(
56
+ join_table,
57
+ from_table,
58
+ source_fk,
59
+ "}o--||",
60
+ nil, # Let the Relationship generate the label
61
+ join_table, # fk_table
62
+ source_fk, # fk_column
63
+ from_table, # pk_table
64
+ model.primary_key, # pk_column
65
+ ),
66
+ Relationship.new(
67
+ join_table,
68
+ to_table_info[:table_name],
69
+ target_fk,
70
+ "}o--||",
71
+ nil, # Let the Relationship generate the label
72
+ join_table, # fk_table
73
+ target_fk, # fk_column
74
+ to_table_info[:table_name], # pk_table
75
+ to_table_info[:primary_key], # pk_column
76
+ ),
77
+ ]
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_relationship_builder"
4
+
5
+ module MermaidRailsErd
6
+ module RelationshipBuilders
7
+ class HasManyRelationshipBuilder < BaseRelationshipBuilder
8
+ def build(model, assoc)
9
+ from_table = model.table_name
10
+ fk = safe_foreign_key(model, assoc)
11
+
12
+ # Skip if we couldn't determine the foreign key
13
+ return [] unless fk
14
+
15
+ to_table_info = resolve_association_model(model, assoc)
16
+
17
+ if to_table_info
18
+ # FK is on target table for has_many
19
+ [Relationship.new(
20
+ to_table_info[:table_name],
21
+ from_table,
22
+ fk,
23
+ "}o--||",
24
+ nil, # Let the Relationship generate the label
25
+ to_table_info[:table_name], # fk_table
26
+ fk, # fk_column
27
+ from_table, # pk_table
28
+ model.primary_key, # pk_column
29
+ )]
30
+ else
31
+ log_missing_table_warning(model, assoc)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_relationship_builder"
4
+
5
+ module MermaidRailsErd
6
+ module RelationshipBuilders
7
+ class HasOneRelationshipBuilder < BaseRelationshipBuilder
8
+ def build(model, assoc)
9
+ from_table = model.table_name
10
+ rel_type = symbol_mapper.map(assoc.macro)
11
+ fk = safe_foreign_key(model, assoc)
12
+
13
+ # Skip if we couldn't determine the foreign key
14
+ return [] unless fk
15
+
16
+ to_table_info = resolve_association_model(model, assoc)
17
+
18
+ # Skip if this is a duplicate one-to-one relationship
19
+ return [] if skip_duplicate_one_to_one?(model, assoc, to_table_info)
20
+
21
+ if to_table_info
22
+ [Relationship.new(
23
+ from_table,
24
+ to_table_info[:table_name],
25
+ fk,
26
+ rel_type,
27
+ nil, # Let the Relationship generate the label
28
+ to_table_info[:table_name], # fk_table
29
+ fk, # fk_column
30
+ from_table, # pk_table
31
+ model.primary_key, # pk_column
32
+ )]
33
+ else
34
+ log_missing_table_warning(model, assoc)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "relationship_builders/belongs_to_relationship_builder"
4
+ require_relative "relationship_builders/has_many_relationship_builder"
5
+ require_relative "relationship_builders/has_one_relationship_builder"
6
+ require_relative "relationship_builders/habtm_relationship_builder"
7
+ require_relative "model_data_collector"
8
+
9
+ module MermaidRailsErd
10
+ class RelationshipRegistry
11
+ attr_reader :builders, :polymorphic_resolver, :model_data_collector
12
+
13
+ def initialize(
14
+ symbol_mapper:,
15
+ association_resolver:,
16
+ polymorphic_resolver:,
17
+ model_data_collector:,
18
+ printed_tables: Set.new
19
+ )
20
+ @polymorphic_resolver = polymorphic_resolver
21
+ @model_data_collector = model_data_collector
22
+ @printed_tables = printed_tables
23
+
24
+ @builders = {
25
+ belongs_to: RelationshipBuilders::BelongsToRelationshipBuilder.new(
26
+ symbol_mapper: symbol_mapper,
27
+ association_resolver: association_resolver,
28
+ model_data_collector: model_data_collector,
29
+ ),
30
+ has_many: RelationshipBuilders::HasManyRelationshipBuilder.new(
31
+ symbol_mapper: symbol_mapper,
32
+ association_resolver: association_resolver,
33
+ model_data_collector: model_data_collector,
34
+ ),
35
+ has_one: RelationshipBuilders::HasOneRelationshipBuilder.new(
36
+ symbol_mapper: symbol_mapper,
37
+ association_resolver: association_resolver,
38
+ model_data_collector: model_data_collector,
39
+ ),
40
+ has_and_belongs_to_many: RelationshipBuilders::HasAndBelongsToManyRelationshipBuilder.new(
41
+ symbol_mapper: symbol_mapper,
42
+ association_resolver: association_resolver,
43
+ printed_tables: printed_tables,
44
+ model_data_collector: model_data_collector,
45
+ ),
46
+ }
47
+ end
48
+
49
+ def build_relationships(model, assoc)
50
+ # Check for polymorphic association first
51
+ if assoc.options[:polymorphic]
52
+ from_table = model.table_name
53
+ rel_type = builders[assoc.macro].symbol_mapper.map(assoc.macro)
54
+ return polymorphic_resolver.resolve(assoc.name.to_s, from_table, rel_type)
55
+ end
56
+
57
+ # Delegate to the appropriate builder
58
+ builder = builders[assoc.macro]
59
+ return builder.build(model, assoc) if builder
60
+
61
+ # If no builder exists for this macro type, return an empty array
62
+ []
63
+ end
64
+
65
+ def build_all_relationships
66
+ relationships = []
67
+
68
+ # Process polymorphic associations first
69
+ @model_data_collector.polymorphic_associations.each do |data|
70
+ model = data[:model]
71
+ assoc = data[:association]
72
+ relationships.concat(build_relationships(model, assoc))
73
+ end
74
+
75
+ # Then process regular associations
76
+ @model_data_collector.regular_associations.each do |data|
77
+ model = data[:model]
78
+ assoc = data[:association]
79
+ relationships.concat(build_relationships(model, assoc))
80
+ end
81
+
82
+ relationships
83
+ end
84
+ end
85
+ end