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.
- checksums.yaml +4 -4
- data/Appraisals +39 -0
- data/CHANGELOG.md +14 -2
- data/README.md +111 -146
- data/Rakefile +17 -3
- data/exe/lagoon +3 -3
- data/gemfiles/adapters.gemfile +15 -0
- data/gemfiles/rails_6.1.gemfile +23 -0
- data/gemfiles/rails_7.0.gemfile +23 -0
- data/gemfiles/rails_7.1.gemfile +23 -0
- data/gemfiles/rails_7.2.gemfile +23 -0
- data/gemfiles/rails_8.0.gemfile +23 -0
- data/gemfiles/rails_8.1.gemfile +23 -0
- data/lib/lagoon/analyzer/action_controller_analyzer.rb +76 -0
- data/lib/lagoon/analyzer/active_record_analyzer.rb +202 -0
- data/lib/lagoon/analyzer/ast/controller_scope_collector.rb +104 -0
- data/lib/lagoon/analyzer/ast/method_reference_visitor.rb +170 -0
- data/lib/lagoon/analyzer/ast_model_reference_analyzer.rb +44 -0
- data/lib/lagoon/analyzer/database_schema_analyzer.rb +83 -0
- data/lib/lagoon/cli.rb +109 -80
- data/lib/lagoon/configuration.rb +42 -6
- data/lib/lagoon/diagram/base.rb +48 -8
- data/lib/lagoon/diagram/controller_diagram.rb +10 -16
- data/lib/lagoon/diagram/controller_model_diagram.rb +28 -0
- data/lib/lagoon/diagram/er_diagram.rb +10 -16
- data/lib/lagoon/diagram/model_diagram.rb +13 -16
- data/lib/lagoon/errors.rb +9 -0
- data/lib/lagoon/options.rb +182 -0
- data/lib/lagoon/parser/application_class_filter.rb +47 -0
- data/lib/lagoon/parser/controller_model_parser.rb +196 -0
- data/lib/lagoon/parser/controller_parser.rb +37 -54
- data/lib/lagoon/parser/model_parser.rb +57 -112
- data/lib/lagoon/parser/schema_parser.rb +120 -67
- data/lib/lagoon/railtie.rb +1 -1
- data/lib/lagoon/renderer/base_renderer.rb +49 -19
- data/lib/lagoon/renderer/class_diagram_renderer.rb +37 -31
- data/lib/lagoon/renderer/controller_model_er_renderer.rb +47 -0
- data/lib/lagoon/renderer/er_diagram_renderer.rb +43 -42
- data/lib/lagoon/result.rb +22 -0
- data/lib/lagoon/version.rb +1 -1
- data/lib/lagoon.rb +70 -21
- data/lib/tasks/lagoon.rake +15 -7
- data/sig/lagoon.rbs +107 -0
- 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
|