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,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lagoon
4
+ class Options
5
+ include Enumerable
6
+
7
+ COMMON_KEYS = %i[output verbose strict eager_load].freeze
8
+ KEYS = {
9
+ model: COMMON_KEYS + %i[
10
+ direction brief show_attributes show_methods include_inheritance exclude specify
11
+ all_models show_belongs_to hide_through all_columns hide_magic hide_types
12
+ include_framework_bases duplicate_sti_attributes
13
+ ],
14
+ controller: COMMON_KEYS + %i[
15
+ direction brief include_inheritance exclude specify all_controllers hide_public
16
+ hide_protected hide_private include_framework_bases
17
+ ],
18
+ er: COMMON_KEYS + %i[exclude specify connections internal_tables],
19
+ controller_model: COMMON_KEYS + %i[
20
+ direction exclude specify all_controllers show_actions helper_models
21
+ ]
22
+ }.transform_values(&:freeze).freeze
23
+
24
+ attr_reader :kind
25
+
26
+ def self.for(kind, raw_options = nil, config: Lagoon.configuration, **keyword_options)
27
+ options = (raw_options || {}).to_h.merge(keyword_options)
28
+ new(kind, options, config: config)
29
+ end
30
+
31
+ def self.allowed_keys(kind)
32
+ KEYS.fetch(kind)
33
+ end
34
+
35
+ def initialize(kind, raw_options = {}, config: Lagoon.configuration)
36
+ @kind = kind.to_sym
37
+ @config = config
38
+ @raw = symbolize_keys(raw_options)
39
+ validate_keys!
40
+ @values = normalize.freeze
41
+ freeze
42
+ end
43
+
44
+ def [](key)
45
+ @values[key.to_sym]
46
+ end
47
+
48
+ def fetch(key, *defaults, &)
49
+ @values.fetch(key.to_sym, *defaults, &)
50
+ end
51
+
52
+ def key?(key)
53
+ @values.key?(key.to_sym)
54
+ end
55
+
56
+ def each(&)
57
+ @values.each(&)
58
+ end
59
+
60
+ def to_h
61
+ @values.dup
62
+ end
63
+
64
+ private
65
+
66
+ def symbolize_keys(raw_options)
67
+ raw_options.to_h.transform_keys(&:to_sym)
68
+ end
69
+
70
+ def validate_keys!
71
+ unknown = @raw.keys - self.class.allowed_keys(@kind)
72
+ return if unknown.empty?
73
+
74
+ raise ConfigurationError, "Unknown #{@kind} option(s): #{unknown.sort.join(', ')}"
75
+ end
76
+
77
+ def normalize
78
+ values = common_values
79
+ case @kind
80
+ when :model then values.merge(model_values)
81
+ when :controller then values.merge(controller_values)
82
+ when :er then values.merge(er_values)
83
+ when :controller_model then values.merge(controller_model_values)
84
+ else raise ConfigurationError, "Unknown diagram kind: #{@kind}"
85
+ end
86
+ end
87
+
88
+ def common_values
89
+ {
90
+ output: optional_path(@raw[:output]),
91
+ verbose: boolean(:verbose, false),
92
+ strict: boolean(:strict, @config.strict),
93
+ eager_load: boolean(:eager_load, true)
94
+ }
95
+ end
96
+
97
+ def model_values
98
+ brief = boolean(:brief, false)
99
+ {
100
+ direction: direction,
101
+ brief: brief,
102
+ show_attributes: brief ? false : boolean(:show_attributes, @config.show_attributes),
103
+ show_methods: brief ? false : boolean(:show_methods, @config.show_methods),
104
+ include_inheritance: boolean(:include_inheritance, @config.include_inheritance),
105
+ exclude: names(@config.exclude_models, @raw[:exclude]),
106
+ specify: names(@raw[:specify]),
107
+ all_models: boolean(:all_models, false),
108
+ show_belongs_to: boolean(:show_belongs_to, false),
109
+ hide_through: boolean(:hide_through, false),
110
+ all_columns: boolean(:all_columns, false),
111
+ hide_magic: boolean(:hide_magic, false),
112
+ hide_types: boolean(:hide_types, false),
113
+ include_framework_bases: boolean(:include_framework_bases, @config.include_framework_bases),
114
+ duplicate_sti_attributes: boolean(:duplicate_sti_attributes, false)
115
+ }
116
+ end
117
+
118
+ def controller_values
119
+ brief = boolean(:brief, false)
120
+ {
121
+ direction: direction,
122
+ brief: brief,
123
+ include_inheritance: boolean(:include_inheritance, @config.include_inheritance),
124
+ exclude: names(@config.exclude_controllers, @raw[:exclude]),
125
+ specify: names(@raw[:specify]),
126
+ all_controllers: boolean(:all_controllers, false),
127
+ hide_public: brief || boolean(:hide_public, false),
128
+ hide_protected: brief || boolean(:hide_protected, false),
129
+ hide_private: brief || boolean(:hide_private, false),
130
+ include_framework_bases: boolean(:include_framework_bases, @config.include_framework_bases)
131
+ }
132
+ end
133
+
134
+ def er_values
135
+ {
136
+ exclude: names(@config.exclude_tables, @raw[:exclude]),
137
+ specify: names(@raw[:specify]),
138
+ connections: @raw[:connections],
139
+ internal_tables: names(@raw.fetch(:internal_tables, @config.internal_tables))
140
+ }
141
+ end
142
+
143
+ def controller_model_values
144
+ {
145
+ direction: direction,
146
+ exclude: names(@config.exclude_controllers, @raw[:exclude]),
147
+ specify: names(@raw[:specify]),
148
+ all_controllers: boolean(:all_controllers, false),
149
+ show_actions: boolean(:show_actions, true),
150
+ helper_models: normalize_helper_models(@raw.fetch(:helper_models, @config.helper_models))
151
+ }
152
+ end
153
+
154
+ def direction
155
+ value = @raw.fetch(:direction, @config.diagram_direction)
156
+ Configuration.validate_direction!(value)
157
+ end
158
+
159
+ def boolean(key, default)
160
+ @raw.key?(key) ? !!@raw[key] : default
161
+ end
162
+
163
+ def names(*groups)
164
+ groups.compact.flatten.map(&:to_s).reject(&:empty?).uniq.sort.freeze
165
+ end
166
+
167
+ def normalize_helper_models(mapping)
168
+ mapping.to_h.each_with_object({}) do |(helper, model), result|
169
+ result[helper.to_s] = model.to_s
170
+ end.freeze
171
+ end
172
+
173
+ def optional_path(path)
174
+ return nil if path.nil?
175
+
176
+ value = path.to_s
177
+ raise ConfigurationError, 'Output path cannot be empty' if value.empty?
178
+
179
+ value.freeze
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'active_support'
5
+ require 'active_support/core_ext/string/inflections'
6
+
7
+ module Lagoon
8
+ module Parser
9
+ class ApplicationClassFilter
10
+ def initialize(directory:, include_all: false)
11
+ @directory = directory
12
+ @include_all = include_all
13
+ end
14
+
15
+ def include?(klass)
16
+ return false unless klass.name
17
+ return true if @include_all
18
+ return true unless defined?(Rails) && Rails.respond_to?(:root)
19
+
20
+ application_directory = File.expand_path(File.join(Rails.root.to_s, 'app', @directory))
21
+ source_locations(klass).any? do |location|
22
+ File.expand_path(location).start_with?("#{application_directory}#{File::SEPARATOR}")
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def source_locations(klass)
29
+ locations = [constant_source_location(klass)]
30
+ method_names = klass.instance_methods(false) +
31
+ klass.private_instance_methods(false) +
32
+ klass.protected_instance_methods(false)
33
+ locations.concat(method_names.filter_map { |name| klass.instance_method(name).source_location&.first })
34
+ locations.compact.uniq
35
+ rescue NameError
36
+ []
37
+ end
38
+
39
+ def constant_source_location(klass)
40
+ namespace_name = klass.name.deconstantize
41
+ constant_name = klass.name.demodulize.to_sym
42
+ namespace = namespace_name.empty? ? Object : namespace_name.safe_constantize
43
+ namespace&.const_source_location(constant_name)&.first
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'active_support'
5
+ require 'active_support/core_ext/string/inflections'
6
+
7
+ module Lagoon
8
+ module Parser
9
+ class ControllerModelParser
10
+ attr_reader :options
11
+
12
+ def initialize(options = {})
13
+ @options = options.is_a?(Options) ? options : Options.for(:controller_model, options)
14
+ @filter = ApplicationClassFilter.new(
15
+ directory: 'controllers',
16
+ include_all: @options[:all_controllers]
17
+ )
18
+ end
19
+
20
+ def parse
21
+ relationships = []
22
+ warnings = []
23
+ model_metadata = load_model_metadata(warnings)
24
+
25
+ load_controllers.sort_by { |controller| controller.name.to_s }.each do |controller|
26
+ next if excluded?(controller)
27
+
28
+ analyze_controller(controller, model_metadata, relationships, warnings)
29
+ end
30
+
31
+ aggregated = aggregate_relationships(relationships)
32
+ {
33
+ relationships: aggregated,
34
+ warnings: warnings,
35
+ counts: { relationships: aggregated.size, skipped: warnings.size }
36
+ }
37
+ end
38
+
39
+ private
40
+
41
+ def load_controllers
42
+ return [] unless defined?(ActionController::Base)
43
+
44
+ Rails.application.eager_load! if options[:eager_load] && defined?(Rails)
45
+ ActionController::Base.descendants
46
+ end
47
+
48
+ def load_model_metadata(warnings)
49
+ return { names: [], associations: {} } unless defined?(ActiveRecord::Base)
50
+
51
+ models = ActiveRecord::Base.descendants.select(&:name)
52
+ associations = models.to_h do |model|
53
+ [model.name, association_mapping(model, warnings)]
54
+ end
55
+ { names: models.map(&:name), associations: associations }
56
+ end
57
+
58
+ def association_mapping(model, warnings)
59
+ model.reflect_on_all_associations.each_with_object({}) do |association, result|
60
+ next if association.options[:polymorphic]
61
+
62
+ result[association.name.to_s] = association.class_name
63
+ rescue NameError => e
64
+ warnings << "Could not resolve #{model.name}.#{association.name}: #{e.message}"
65
+ end
66
+ end
67
+
68
+ def excluded?(controller)
69
+ controller_name = controller.name
70
+ return true unless controller_name
71
+ return true unless @filter.include?(controller)
72
+ return true if options[:exclude].include?(controller_name)
73
+ return !options[:specify].include?(controller_name) if options[:specify].any?
74
+
75
+ false
76
+ end
77
+
78
+ def analyze_controller(controller, model_metadata, relationships, warnings)
79
+ actions = controller.action_methods.to_a.map(&:to_s).sort
80
+ return if actions.empty?
81
+
82
+ source_context = source_context_for(controller, actions)
83
+ if source_context[:files].empty?
84
+ warnings << "No source file found for #{controller.name}"
85
+ return
86
+ end
87
+
88
+ action_models = analyze_source_files(
89
+ source_context[:files],
90
+ actions,
91
+ source_context[:controller_names],
92
+ model_metadata
93
+ )
94
+ add_relationships(controller.name, action_models, relationships)
95
+ rescue Errno::ENOENT, Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError, ParseError => e
96
+ raise if options[:strict]
97
+
98
+ warnings << "Failed to parse #{controller.name}: #{e.message}"
99
+ end
100
+
101
+ def source_context_for(controller, actions)
102
+ owners = actions.filter_map do |action|
103
+ controller.instance_method(action).owner
104
+ rescue NameError
105
+ nil
106
+ end
107
+ owners << controller
108
+ owners.concat(callback_owners(controller))
109
+ owners.select!(&:name)
110
+
111
+ {
112
+ controller_names: owners.map(&:name).uniq,
113
+ files: owners.flat_map { |owner| source_files_for(owner) }.compact.uniq.select { |file| File.file?(file) }
114
+ }
115
+ end
116
+
117
+ def source_files_for(controller)
118
+ method_names = controller.instance_methods(false) +
119
+ controller.private_instance_methods(false) +
120
+ controller.protected_instance_methods(false)
121
+ files = method_names.filter_map do |method_name|
122
+ controller.instance_method(method_name).source_location&.first
123
+ end
124
+ constant_location = constant_source_location(controller)
125
+ files << constant_location if constant_location
126
+ files << conventional_source_file(controller) if files.empty?
127
+ files
128
+ rescue NameError
129
+ [conventional_source_file(controller)]
130
+ end
131
+
132
+ def constant_source_location(controller)
133
+ namespace_name = controller.name.deconstantize
134
+ namespace = namespace_name.empty? ? Object : namespace_name.safe_constantize
135
+ namespace&.const_source_location(controller.name.demodulize.to_sym)&.first
136
+ end
137
+
138
+ def conventional_source_file(controller)
139
+ return nil unless defined?(Rails) && Rails.respond_to?(:root)
140
+
141
+ Rails.root.join('app', 'controllers', "#{controller.name.underscore}.rb").to_s
142
+ end
143
+
144
+ def callback_methods(controller)
145
+ return [] unless controller.respond_to?(:_process_action_callbacks)
146
+
147
+ controller._process_action_callbacks.filter_map do |callback|
148
+ callback.filter.to_s if callback.filter.is_a?(Symbol)
149
+ end.uniq
150
+ end
151
+
152
+ def callback_owners(controller)
153
+ callback_methods(controller).filter_map do |method_name|
154
+ controller.instance_method(method_name).owner
155
+ rescue NameError
156
+ nil
157
+ end
158
+ end
159
+
160
+ def analyze_source_files(file_paths, action_names, controller_names, model_metadata)
161
+ require_relative '../analyzer/ast_model_reference_analyzer'
162
+ analyzer = Lagoon::Analyzer::AstModelReferenceAnalyzer.new
163
+ analyzer.analyze(
164
+ file_paths,
165
+ action_names,
166
+ controller_names: controller_names,
167
+ model_names: model_metadata[:names],
168
+ associations: model_metadata[:associations],
169
+ helper_models: options[:helper_models]
170
+ )
171
+ end
172
+
173
+ def add_relationships(controller_name, action_models, relationships)
174
+ action_models.each do |action, models|
175
+ models.each do |model|
176
+ relationships << { controller: controller_name, action: action, model: model }
177
+ end
178
+ end
179
+ end
180
+
181
+ def aggregate_relationships(relationships)
182
+ grouped = relationships.group_by { |relationship| [relationship[:controller], relationship[:model]] }
183
+
184
+ aggregated = grouped.map do |(controller, model), items|
185
+ {
186
+ controller: controller,
187
+ model: model,
188
+ actions: items.map { |item| item[:action] }.sort.uniq
189
+ }
190
+ end
191
+
192
+ aggregated.sort_by { |relationship| [relationship[:controller], relationship[:model]] }
193
+ end
194
+ end
195
+ end
196
+ end
@@ -6,8 +6,9 @@ module Lagoon
6
6
  attr_reader :options, :config
7
7
 
8
8
  def initialize(options = {})
9
- @options = options
10
- @config = Lagoon.configuration
9
+ @options = options.is_a?(Options) ? options : Options.for(:controller, options)
10
+ @analyzer = Lagoon::Analyzer::ActionControllerAnalyzer.new
11
+ @filter = ApplicationClassFilter.new(directory: 'controllers', include_all: @options[:all_controllers])
11
12
  end
12
13
 
13
14
  def parse
@@ -15,80 +16,62 @@ module Lagoon
15
16
  classes = []
16
17
  relationships = []
17
18
 
18
- controllers.each do |controller|
19
+ warnings = []
20
+
21
+ controllers.sort_by { |controller| controller.name.to_s }.each do |controller|
19
22
  next if excluded?(controller)
20
23
 
21
- classes << parse_controller(controller)
22
- relationships.concat(extract_inheritance(controller)) if config.include_inheritance
24
+ controller_data = @analyzer.analyze_controller(controller, analysis_options)
25
+ classes << controller_data
26
+
27
+ if options[:include_inheritance]
28
+ relationships.concat(
29
+ @analyzer.extract_inheritance(
30
+ controller,
31
+ include_framework_base: options[:include_framework_bases]
32
+ )
33
+ )
34
+ end
35
+ rescue StandardError => e
36
+ raise if options[:strict]
37
+
38
+ warnings << "Failed to analyze controller #{controller.name || '(anonymous)'}: #{e.message}"
23
39
  end
24
40
 
25
41
  {
26
- classes: classes,
27
- relationships: relationships
42
+ classes: classes.sort_by { |controller| controller[:name] },
43
+ relationships: relationships.sort_by { |relationship| [relationship[:source], relationship[:target]] },
44
+ warnings: warnings,
45
+ counts: { classes: classes.size, relationships: relationships.size, skipped: warnings.size }
28
46
  }
29
47
  end
30
48
 
31
49
  private
32
50
 
33
51
  def load_controllers
34
- # Railsアプリケーションの全コントローラをロード
35
- return [] unless defined?(Rails)
52
+ return [] unless defined?(ActionController::Base)
36
53
 
37
- Rails.application.eager_load!
54
+ Rails.application.eager_load! if options[:eager_load] && defined?(Rails)
38
55
  ActionController::Base.descendants
39
56
  end
40
57
 
41
58
  def excluded?(controller)
42
59
  controller_name = controller.name
43
- config.exclude_controllers.include?(controller_name)
60
+ return true unless controller_name
61
+ return true unless @filter.include?(controller)
62
+ return true if options[:exclude].include?(controller_name)
63
+ return !options[:specify].include?(controller_name) if options[:specify].any?
64
+
65
+ false
44
66
  end
45
67
 
46
- def parse_controller(controller)
68
+ def analysis_options
47
69
  {
48
- name: controller.name,
49
- abstract: false,
50
- attributes: [],
51
- methods: extract_methods(controller)
70
+ hide_public: options[:hide_public],
71
+ hide_protected: options[:hide_protected],
72
+ hide_private: options[:hide_private]
52
73
  }
53
74
  end
54
-
55
- def extract_methods(controller)
56
- methods = []
57
-
58
- # Public methods
59
- unless options[:hide_public]
60
- public_methods = controller.action_methods.to_a
61
- methods.concat(public_methods.map { |m| { name: m, visibility: "+" } })
62
- end
63
-
64
- # Protected methods
65
- unless options[:hide_protected]
66
- protected_methods = controller.protected_instance_methods(false)
67
- methods.concat(protected_methods.map { |m| { name: m, visibility: "#" } })
68
- end
69
-
70
- # Private methods
71
- unless options[:hide_private]
72
- private_methods = controller.private_instance_methods(false)
73
- methods.concat(private_methods.map { |m| { name: m, visibility: "-" } })
74
- end
75
-
76
- methods
77
- end
78
-
79
- def extract_inheritance(controller)
80
- return [] if controller.superclass == ActionController::Base
81
- return [] unless controller.superclass.name
82
-
83
- [{
84
- source: controller.superclass.name,
85
- target: controller.name,
86
- type: :inheritance,
87
- label: nil
88
- }]
89
- rescue StandardError
90
- []
91
- end
92
75
  end
93
76
  end
94
77
  end