lagoon 0.1.0 → 0.2.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/Appraisals +39 -0
  3. data/CHANGELOG.md +14 -2
  4. data/README.md +111 -146
  5. data/Rakefile +17 -3
  6. data/exe/lagoon +3 -3
  7. data/gemfiles/adapters.gemfile +15 -0
  8. data/gemfiles/rails_6.1.gemfile +23 -0
  9. data/gemfiles/rails_7.0.gemfile +23 -0
  10. data/gemfiles/rails_7.1.gemfile +23 -0
  11. data/gemfiles/rails_7.2.gemfile +23 -0
  12. data/gemfiles/rails_8.0.gemfile +23 -0
  13. data/gemfiles/rails_8.1.gemfile +23 -0
  14. data/lib/lagoon/analyzer/action_controller_analyzer.rb +76 -0
  15. data/lib/lagoon/analyzer/active_record_analyzer.rb +202 -0
  16. data/lib/lagoon/analyzer/ast/controller_scope_collector.rb +104 -0
  17. data/lib/lagoon/analyzer/ast/method_reference_visitor.rb +170 -0
  18. data/lib/lagoon/analyzer/ast_model_reference_analyzer.rb +44 -0
  19. data/lib/lagoon/analyzer/database_schema_analyzer.rb +83 -0
  20. data/lib/lagoon/cli.rb +109 -80
  21. data/lib/lagoon/configuration.rb +42 -6
  22. data/lib/lagoon/diagram/base.rb +48 -8
  23. data/lib/lagoon/diagram/controller_diagram.rb +10 -16
  24. data/lib/lagoon/diagram/controller_model_diagram.rb +28 -0
  25. data/lib/lagoon/diagram/er_diagram.rb +10 -16
  26. data/lib/lagoon/diagram/model_diagram.rb +13 -16
  27. data/lib/lagoon/errors.rb +9 -0
  28. data/lib/lagoon/options.rb +182 -0
  29. data/lib/lagoon/parser/application_class_filter.rb +47 -0
  30. data/lib/lagoon/parser/controller_model_parser.rb +196 -0
  31. data/lib/lagoon/parser/controller_parser.rb +37 -54
  32. data/lib/lagoon/parser/model_parser.rb +57 -112
  33. data/lib/lagoon/parser/schema_parser.rb +120 -67
  34. data/lib/lagoon/railtie.rb +1 -1
  35. data/lib/lagoon/renderer/base_renderer.rb +49 -19
  36. data/lib/lagoon/renderer/class_diagram_renderer.rb +37 -31
  37. data/lib/lagoon/renderer/controller_model_er_renderer.rb +47 -0
  38. data/lib/lagoon/renderer/er_diagram_renderer.rb +43 -42
  39. data/lib/lagoon/result.rb +22 -0
  40. data/lib/lagoon/version.rb +1 -1
  41. data/lib/lagoon.rb +70 -21
  42. data/lib/tasks/lagoon.rake +15 -7
  43. data/sig/lagoon.rbs +107 -0
  44. metadata +57 -7
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lagoon
4
+ module Analyzer
5
+ # Analyzes ActionController classes to extract metadata
6
+ # including methods and inheritance
7
+ class ActionControllerAnalyzer
8
+ # Analyze a single controller and return its metadata
9
+ #
10
+ # @param controller [Class] ActionController class
11
+ # @param options [Hash] Analysis options
12
+ # @return [Hash] Controller metadata
13
+ def analyze_controller(controller, options = {})
14
+ {
15
+ name: controller.name,
16
+ abstract: abstract_controller?(controller),
17
+ attributes: [],
18
+ methods: extract_methods(controller, options)
19
+ }
20
+ end
21
+
22
+ # Extract inheritance relationship from a controller
23
+ #
24
+ # @param controller [Class] ActionController class
25
+ # @return [Array<Hash>] Inheritance metadata (empty or single element)
26
+ def extract_inheritance(controller, include_framework_base: false)
27
+ return [] if controller.superclass == ActionController::Base && !include_framework_base
28
+ return [] unless controller.superclass.name
29
+ return [] if !include_framework_base && controller.superclass.name.start_with?('ActionController::')
30
+
31
+ [{
32
+ source: controller.superclass.name,
33
+ target: controller.name,
34
+ type: :inheritance,
35
+ label: nil
36
+ }]
37
+ rescue NameError
38
+ []
39
+ end
40
+
41
+ private
42
+
43
+ def extract_methods(controller, options = {})
44
+ methods = []
45
+
46
+ # Public methods (action methods)
47
+ unless options[:hide_public]
48
+ declared_public = controller.public_instance_methods(false).map(&:to_s)
49
+ public_methods = controller.action_methods.to_a.map(&:to_s) & declared_public
50
+ methods.concat(public_methods.map { |m| { name: m, visibility: '+' } })
51
+ end
52
+
53
+ # Protected methods
54
+ unless options[:hide_protected]
55
+ protected_methods = controller.protected_instance_methods(false).map(&:to_s)
56
+ methods.concat(protected_methods.map { |m| { name: m, visibility: '#' } })
57
+ end
58
+
59
+ # Private methods
60
+ unless options[:hide_private]
61
+ private_methods = controller.private_instance_methods(false).map(&:to_s)
62
+ methods.concat(private_methods.map { |m| { name: m, visibility: '-' } })
63
+ end
64
+
65
+ methods.sort_by { |method| [method[:visibility], method[:name]] }
66
+ end
67
+
68
+ def abstract_controller?(controller)
69
+ return controller.abstract? if controller.respond_to?(:abstract?)
70
+ return controller.abstract_controller? if controller.respond_to?(:abstract_controller?)
71
+
72
+ false
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lagoon
4
+ module Analyzer
5
+ # Analyzes ActiveRecord models to extract metadata
6
+ # including columns, methods, associations, and inheritance
7
+ class ActiveRecordAnalyzer
8
+ # Analyze a single model and return its metadata
9
+ #
10
+ # @param model [Class] ActiveRecord model class
11
+ # @param options [Hash] Analysis options
12
+ # @return [Hash] Model metadata
13
+ def analyze_model(model, options = {})
14
+ {
15
+ name: model.name,
16
+ abstract: model.abstract_class?,
17
+ attributes: extract_columns(model, options),
18
+ methods: extract_methods(model, options)
19
+ }
20
+ end
21
+
22
+ # Extract associations from a model
23
+ #
24
+ # @param model [Class] ActiveRecord model class
25
+ # @param options [Hash] Extraction options
26
+ # @return [Array<Hash>] Association metadata
27
+ def extract_associations(model, options = {})
28
+ associations = []
29
+
30
+ model.reflect_on_all_associations.each do |assoc|
31
+ next if assoc.options[:through] && options[:hide_through]
32
+
33
+ association_data = build_association(model, assoc, options)
34
+ associations << association_data if association_data
35
+ end
36
+
37
+ associations
38
+ end
39
+
40
+ # Extract inheritance relationship from a model
41
+ #
42
+ # @param model [Class] ActiveRecord model class
43
+ # @return [Array<Hash>] Inheritance metadata (empty or single element)
44
+ def extract_inheritance(model)
45
+ extract_inheritance_with_options(model)
46
+ end
47
+
48
+ def extract_inheritance_with_options(model, include_framework_base: false)
49
+ return [] if model.superclass == ActiveRecord::Base && !include_framework_base
50
+ return [] if model.superclass.nil? || model.superclass.name.nil?
51
+ return [] if !include_framework_base && framework_model?(model.superclass)
52
+
53
+ [{
54
+ source: model.superclass.name,
55
+ target: model.name,
56
+ type: :inheritance,
57
+ label: nil
58
+ }]
59
+ rescue NameError
60
+ []
61
+ end
62
+
63
+ private
64
+
65
+ def extract_columns(model, options = {})
66
+ return [] unless options.fetch(:show_attributes, true)
67
+ return [] unless model.table_exists?
68
+ return [] if sti_subclass?(model) && !options[:duplicate_sti_attributes]
69
+
70
+ columns = model.columns
71
+ if options[:hide_magic] && !options[:all_columns]
72
+ columns = columns.reject do |column|
73
+ magic_field?(column.name)
74
+ end
75
+ end
76
+
77
+ columns.sort_by(&:name).map do |column|
78
+ {
79
+ name: column.name,
80
+ type: column.type,
81
+ visibility: '+'
82
+ }
83
+ end
84
+ end
85
+
86
+ def magic_field?(field_name)
87
+ %w[id created_at updated_at].include?(field_name)
88
+ end
89
+
90
+ def extract_methods(model, options = {})
91
+ return [] unless options[:show_methods]
92
+
93
+ declared_methods(model, :public_instance_methods, '+') +
94
+ declared_methods(model, :protected_instance_methods, '#') +
95
+ declared_methods(model, :private_instance_methods, '-')
96
+ end
97
+
98
+ def build_association(model, assoc, options = {})
99
+ case assoc.macro
100
+ when :belongs_to
101
+ return nil unless options[:show_belongs_to]
102
+
103
+ {
104
+ source: model.name,
105
+ target: association_target(assoc),
106
+ type: :association,
107
+ macro: :belongs_to,
108
+ label: association_label(assoc),
109
+ source_cardinality: '1',
110
+ target_cardinality: belongs_to_optional?(model, assoc) ? '0..1' : '1',
111
+ polymorphic: !assoc.options[:polymorphic].nil?
112
+ }
113
+ when :has_one
114
+ {
115
+ source: model.name,
116
+ target: assoc.class_name,
117
+ type: :association,
118
+ macro: :has_one,
119
+ label: association_label(assoc),
120
+ source_cardinality: '1',
121
+ target_cardinality: '0..1'
122
+ }
123
+ when :has_many
124
+ {
125
+ source: model.name,
126
+ target: assoc.class_name,
127
+ type: :association,
128
+ macro: :has_many,
129
+ label: association_label(assoc),
130
+ source_cardinality: '1',
131
+ target_cardinality: '*'
132
+ }
133
+ when :has_and_belongs_to_many
134
+ {
135
+ source: model.name,
136
+ target: assoc.class_name,
137
+ type: :association,
138
+ macro: :has_and_belongs_to_many,
139
+ label: association_label(assoc),
140
+ source_cardinality: '*',
141
+ target_cardinality: '*'
142
+ }
143
+ end
144
+ rescue NameError
145
+ nil
146
+ end
147
+
148
+ def declared_methods(model, query, visibility)
149
+ model.public_send(query, false).select { |name| application_method?(model, name) }
150
+ .sort_by(&:to_s).map do |name|
151
+ { name: name.to_s, visibility: visibility }
152
+ end
153
+ end
154
+
155
+ def application_method?(model, method_name)
156
+ source_file = model.instance_method(method_name).source_location&.first
157
+ return false unless source_file
158
+ return application_model_file?(source_file) if defined?(Rails) && Rails.respond_to?(:root)
159
+
160
+ !source_file.match?(%r{/gems/(?:activerecord|activesupport)-})
161
+ rescue NameError
162
+ false
163
+ end
164
+
165
+ def application_model_file?(source_file)
166
+ models_path = File.expand_path(File.join(Rails.root.to_s, 'app', 'models'))
167
+ File.expand_path(source_file).start_with?("#{models_path}#{File::SEPARATOR}")
168
+ end
169
+
170
+ def association_target(association)
171
+ return association.name.to_s.camelize if association.options[:polymorphic]
172
+
173
+ association.class_name
174
+ end
175
+
176
+ def association_label(association)
177
+ label = "#{association.macro} #{association.name}"
178
+ label += " through #{association.options[:through]}" if association.options[:through]
179
+ label += ' (polymorphic)' if association.options[:polymorphic]
180
+ label
181
+ end
182
+
183
+ def belongs_to_optional?(model, association)
184
+ return association.options[:optional] if association.options.key?(:optional)
185
+ return !association.options[:required] if association.options.key?(:required)
186
+
187
+ return false unless model.respond_to?(:belongs_to_required_by_default)
188
+
189
+ required = model.belongs_to_required_by_default
190
+ required.nil? ? false : !required
191
+ end
192
+
193
+ def sti_subclass?(model)
194
+ model.respond_to?(:base_class) && model.base_class != model && model.table_name == model.base_class.table_name
195
+ end
196
+
197
+ def framework_model?(model)
198
+ model.name.start_with?('ActiveRecord::')
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lagoon
4
+ module Analyzer
5
+ class ControllerScopeCollector < Prism::Visitor
6
+ Callback = Data.define(:method_name, :only_actions, :except_actions) do
7
+ def applies_to?(action)
8
+ return false if except_actions.include?(action)
9
+ return true if only_actions.empty?
10
+
11
+ only_actions.include?(action)
12
+ end
13
+ end
14
+
15
+ attr_reader :methods
16
+
17
+ def initialize(controller_names: nil)
18
+ super()
19
+ @controller_names = Array(controller_names).compact.to_set(&:to_s)
20
+ @methods = {}
21
+ @callbacks = []
22
+ @namespace = []
23
+ end
24
+
25
+ def callbacks_for(action)
26
+ @callbacks.select { |callback| callback.applies_to?(action) }.map(&:method_name)
27
+ end
28
+
29
+ def visit_module_node(node)
30
+ with_namespace(constant_path_name(node.constant_path)) { super }
31
+ end
32
+
33
+ def visit_class_node(node)
34
+ class_name = qualified_name(node.constant_path)
35
+ return unless @controller_names.empty? || @controller_names.include?(class_name)
36
+
37
+ Array(node.body&.body).each do |child|
38
+ case child
39
+ when Prism::DefNode then @methods[child.name.to_s] = child
40
+ when Prism::CallNode then collect_callback(child)
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def with_namespace(name)
48
+ @namespace << name
49
+ yield
50
+ ensure
51
+ @namespace.pop
52
+ end
53
+
54
+ def qualified_name(node)
55
+ path = constant_path_name(node)
56
+ return path if path.include?('::') || @namespace.empty?
57
+
58
+ [*@namespace, path].join('::')
59
+ end
60
+
61
+ def constant_path_name(node)
62
+ case node
63
+ when Prism::ConstantReadNode
64
+ node.name.to_s
65
+ when Prism::ConstantPathNode
66
+ [constant_path_name(node.parent), node.name.to_s].reject(&:empty?).join('::')
67
+ else
68
+ ''
69
+ end
70
+ end
71
+
72
+ def collect_callback(node)
73
+ return unless %i[before_action prepend_before_action append_before_action].include?(node.name)
74
+
75
+ arguments = Array(node.arguments&.arguments)
76
+ method_names = arguments.take_while { |argument| argument.is_a?(Prism::SymbolNode) }
77
+ .map(&:unescaped)
78
+ options = callback_options(arguments.find { |argument| argument.is_a?(Prism::KeywordHashNode) })
79
+ method_names.each do |method_name|
80
+ @callbacks << Callback.new(
81
+ method_name: method_name,
82
+ only_actions: options.fetch('only', []),
83
+ except_actions: options.fetch('except', [])
84
+ )
85
+ end
86
+ end
87
+
88
+ def callback_options(keyword_hash)
89
+ return {} unless keyword_hash
90
+
91
+ keyword_hash.elements.each_with_object({}) do |element, result|
92
+ next unless element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode)
93
+
94
+ result[element.key.unescaped] = symbol_values(element.value)
95
+ end
96
+ end
97
+
98
+ def symbol_values(node)
99
+ nodes = node.is_a?(Prism::ArrayNode) ? node.elements : [node]
100
+ nodes.filter_map { |value| value.unescaped if value.is_a?(Prism::SymbolNode) }
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lagoon
4
+ module Analyzer
5
+ class MethodReferenceVisitor < Prism::Visitor
6
+ def initialize(methods:, model_names:, associations:, helper_models:)
7
+ super()
8
+ @methods = methods
9
+ @model_names = model_names.to_set(&:to_s)
10
+ @associations = normalize_associations(associations)
11
+ @helper_models = helper_models.to_h.transform_keys(&:to_s).transform_values(&:to_s)
12
+ end
13
+
14
+ def models_for(action, callbacks: [])
15
+ @models = Set.new
16
+ @instance_variables = {}
17
+ @method_stack = []
18
+
19
+ callbacks.each { |callback| analyze_method(callback.to_s) }
20
+ analyze_method(action.to_s)
21
+ @models
22
+ end
23
+
24
+ def visit_def_node(_node)
25
+ # Nested method definitions have an independent scope.
26
+ end
27
+
28
+ def visit_constant_read_node(node)
29
+ record_model(node.name.to_s)
30
+ end
31
+
32
+ def visit_constant_path_node(node)
33
+ record_model(constant_path_name(node))
34
+ end
35
+
36
+ def visit_local_variable_write_node(node)
37
+ node.value&.accept(self)
38
+ @local_variables[node.name.to_s] = infer_value(node.value)
39
+ end
40
+
41
+ def visit_instance_variable_write_node(node)
42
+ node.value&.accept(self)
43
+ @instance_variables[node.name.to_s] = infer_value(node.value)
44
+ end
45
+
46
+ def visit_call_node(node)
47
+ node.receiver&.accept(self)
48
+ node.arguments&.accept(self)
49
+
50
+ returned_models = infer_call(node)
51
+ returned_models.each { |model| record_model(model) }
52
+ visit_block(node.block, returned_models) if node.block
53
+ end
54
+
55
+ private
56
+
57
+ def analyze_method(method_name)
58
+ return Set.new if @method_stack.include?(method_name)
59
+
60
+ method_node = @methods[method_name]
61
+ return Set.new unless method_node
62
+
63
+ previous_locals = @local_variables
64
+ @local_variables = {}
65
+ @method_stack << method_name
66
+ begin
67
+ method_node.body&.accept(self)
68
+ infer_last_expression(method_node.body)
69
+ ensure
70
+ @method_stack.pop
71
+ @local_variables = previous_locals
72
+ end
73
+ end
74
+
75
+ def infer_last_expression(body)
76
+ last_node = body.respond_to?(:body) ? body.body.last : body
77
+ infer_value(last_node)
78
+ end
79
+
80
+ def infer_call(node)
81
+ if node.receiver.nil?
82
+ helper_model = @helper_models[node.name.to_s]
83
+ return Set[helper_model] if known_model?(helper_model)
84
+ return analyze_method(node.name.to_s) if no_arguments?(node)
85
+
86
+ return Set.new
87
+ end
88
+
89
+ receiver_models = infer_value(node.receiver)
90
+ association_models = receiver_models.filter_map do |model|
91
+ @associations.dig(model, node.name.to_s)
92
+ end.flatten.to_set
93
+
94
+ association_models.empty? ? receiver_models : association_models
95
+ end
96
+
97
+ def infer_value(node)
98
+ case node
99
+ when Prism::ConstantReadNode
100
+ known_set(node.name.to_s)
101
+ when Prism::ConstantPathNode
102
+ known_set(constant_path_name(node))
103
+ when Prism::LocalVariableReadNode
104
+ @local_variables.fetch(node.name.to_s, Set.new)
105
+ when Prism::InstanceVariableReadNode
106
+ @instance_variables.fetch(node.name.to_s, Set.new)
107
+ when Prism::CallNode
108
+ infer_call(node)
109
+ else
110
+ Set.new
111
+ end
112
+ end
113
+
114
+ def visit_block(block, yielded_models)
115
+ previous = {}
116
+ block_parameter_names(block).each do |name|
117
+ previous[name] = @local_variables[name]
118
+ @local_variables[name] = yielded_models
119
+ end
120
+ block.body&.accept(self)
121
+ ensure
122
+ previous&.each do |name, value|
123
+ value ? @local_variables[name] = value : @local_variables.delete(name)
124
+ end
125
+ end
126
+
127
+ def block_parameter_names(block)
128
+ parameters = block.parameters&.parameters
129
+ return [] unless parameters
130
+
131
+ parameters.requireds.map { |parameter| parameter.name.to_s }
132
+ end
133
+
134
+ def no_arguments?(node)
135
+ node.arguments.nil? || node.arguments.arguments.empty?
136
+ end
137
+
138
+ def record_model(name)
139
+ @models << name if known_model?(name)
140
+ end
141
+
142
+ def known_set(name)
143
+ known_model?(name) ? Set[name] : Set.new
144
+ end
145
+
146
+ def known_model?(name)
147
+ name && @model_names.include?(name)
148
+ end
149
+
150
+ def normalize_associations(associations)
151
+ associations.to_h.each_with_object({}) do |(model, mapping), result|
152
+ result[model.to_s] = mapping.to_h.each_with_object({}) do |(name, targets), model_result|
153
+ model_result[name.to_s] = Array(targets).map(&:to_s)
154
+ end
155
+ end
156
+ end
157
+
158
+ def constant_path_name(node)
159
+ case node
160
+ when Prism::ConstantReadNode
161
+ node.name.to_s
162
+ when Prism::ConstantPathNode
163
+ [constant_path_name(node.parent), node.name.to_s].reject(&:empty?).join('::')
164
+ else
165
+ ''
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+ require_relative 'ast/controller_scope_collector'
5
+ require_relative 'ast/method_reference_visitor'
6
+
7
+ module Lagoon
8
+ module Analyzer
9
+ class AstModelReferenceAnalyzer
10
+ def analyze(file_paths, action_names, controller_names: nil, model_names: [], associations: {},
11
+ helper_models: {}, callback_methods: [])
12
+ collector = ControllerScopeCollector.new(controller_names: controller_names)
13
+ Array(file_paths).uniq.each do |file_path|
14
+ parse_file(file_path).accept(collector)
15
+ end
16
+
17
+ visitor = MethodReferenceVisitor.new(
18
+ methods: collector.methods,
19
+ model_names: model_names,
20
+ associations: associations,
21
+ helper_models: helper_models
22
+ )
23
+
24
+ action_names.each_with_object({}) do |action_name, result|
25
+ action = action_name.to_s
26
+ next unless collector.methods.key?(action)
27
+
28
+ callbacks = collector.callbacks_for(action) | callback_methods.map(&:to_s)
29
+ result[action] = visitor.models_for(action, callbacks: callbacks)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def parse_file(file_path)
36
+ result = Prism.parse_file(file_path)
37
+ return result.value if result.success?
38
+
39
+ details = result.errors.map(&:message).uniq.join('; ')
40
+ raise ParseError, "Syntax error in #{file_path}: #{details}"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lagoon
4
+ module Analyzer
5
+ class DatabaseSchemaAnalyzer
6
+ def analyze_table(table_name, columns, primary_keys: [], foreign_keys: [], indexes: [])
7
+ foreign_key_columns = foreign_keys.flat_map { |foreign_key| Array(foreign_key.column).map(&:to_s) }
8
+ unique_columns = single_column_unique_indexes(indexes)
9
+ primary_key_columns = Array(primary_keys).map(&:to_s)
10
+
11
+ {
12
+ name: table_name,
13
+ attributes: columns.map do |column|
14
+ analyze_column(
15
+ column,
16
+ primary_keys: primary_key_columns,
17
+ foreign_keys: foreign_key_columns,
18
+ unique_columns: unique_columns
19
+ )
20
+ end
21
+ }
22
+ end
23
+
24
+ def analyze_column(column, primary_keys: [], foreign_keys: [], unique_columns: [])
25
+ {
26
+ name: column.name,
27
+ type: column.type,
28
+ primary_key: primary_keys.include?(column.name.to_s),
29
+ foreign_key: foreign_keys.include?(column.name.to_s),
30
+ unique: unique_columns.include?(column.name.to_s)
31
+ }
32
+ end
33
+
34
+ def extract_foreign_keys(table_name, columns, foreign_keys:, indexes: [], primary_keys: [], table_prefix: nil)
35
+ columns_by_name = columns.to_h { |column| [column.name.to_s, column] }
36
+ primary_key_columns = Array(primary_keys).map(&:to_s)
37
+
38
+ foreign_keys.filter_map do |foreign_key|
39
+ column_names = Array(foreign_key.column).map(&:to_s)
40
+ foreign_key_columns = column_names.filter_map { |name| columns_by_name[name] }
41
+ next if foreign_key_columns.empty?
42
+
43
+ unique = unique_foreign_key?(column_names, indexes)
44
+ nullable = foreign_key_columns.any? { |column| column.respond_to?(:null) ? column.null : true }
45
+ identifying = (column_names - primary_key_columns).empty?
46
+
47
+ {
48
+ source: qualify_table(foreign_key.to_table, table_prefix),
49
+ target: table_name,
50
+ label: unique ? 'has one' : 'has many',
51
+ source_cardinality: nullable ? :zero_or_one : :one,
52
+ target_cardinality: unique ? :zero_or_one : :zero_or_many,
53
+ identifying: identifying,
54
+ foreign_key: column_names
55
+ }
56
+ end
57
+ end
58
+
59
+ def internal_table?(table_name, internal_tables: %w[schema_migrations ar_internal_metadata])
60
+ internal_tables.include?(table_name.to_s)
61
+ end
62
+
63
+ private
64
+
65
+ def single_column_unique_indexes(indexes)
66
+ indexes.select { |index| index.unique && Array(index.columns).size == 1 }
67
+ .map { |index| Array(index.columns).first.to_s }
68
+ end
69
+
70
+ def unique_foreign_key?(column_names, indexes)
71
+ indexes.any? do |index|
72
+ index.unique && Array(index.columns).map(&:to_s).sort == column_names.sort
73
+ end
74
+ end
75
+
76
+ def qualify_table(table_name, prefix)
77
+ return table_name.to_s unless prefix
78
+
79
+ "#{prefix}.#{table_name}"
80
+ end
81
+ end
82
+ end
83
+ end