rails_lens 0.2.12 → 0.3.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +88 -72
  4. data/lib/rails_lens/analyzers/association_analyzer.rb +3 -10
  5. data/lib/rails_lens/analyzers/best_practices_analyzer.rb +11 -36
  6. data/lib/rails_lens/analyzers/callbacks.rb +302 -0
  7. data/lib/rails_lens/analyzers/column_analyzer.rb +6 -6
  8. data/lib/rails_lens/analyzers/composite_keys.rb +2 -5
  9. data/lib/rails_lens/analyzers/database_constraints.rb +4 -6
  10. data/lib/rails_lens/analyzers/delegated_types.rb +4 -7
  11. data/lib/rails_lens/analyzers/enums.rb +5 -11
  12. data/lib/rails_lens/analyzers/foreign_key_analyzer.rb +2 -2
  13. data/lib/rails_lens/analyzers/generated_columns.rb +4 -6
  14. data/lib/rails_lens/analyzers/index_analyzer.rb +4 -10
  15. data/lib/rails_lens/analyzers/inheritance.rb +30 -31
  16. data/lib/rails_lens/analyzers/notes.rb +29 -39
  17. data/lib/rails_lens/analyzers/performance_analyzer.rb +3 -26
  18. data/lib/rails_lens/annotation_pipeline.rb +1 -0
  19. data/lib/rails_lens/cli.rb +1 -0
  20. data/lib/rails_lens/commands.rb +23 -1
  21. data/lib/rails_lens/configuration.rb +4 -1
  22. data/lib/rails_lens/erd/visualizer.rb +0 -1
  23. data/lib/rails_lens/extensions/closure_tree_ext.rb +11 -11
  24. data/lib/rails_lens/mailer/annotator.rb +3 -3
  25. data/lib/rails_lens/model_detector.rb +49 -3
  26. data/lib/rails_lens/note_codes.rb +59 -0
  27. data/lib/rails_lens/providers/callbacks_provider.rb +24 -0
  28. data/lib/rails_lens/providers/extensions_provider.rb +1 -1
  29. data/lib/rails_lens/providers/view_provider.rb +6 -20
  30. data/lib/rails_lens/schema/adapters/base.rb +39 -2
  31. data/lib/rails_lens/schema/adapters/database_info.rb +11 -17
  32. data/lib/rails_lens/schema/adapters/mysql.rb +75 -0
  33. data/lib/rails_lens/schema/adapters/postgresql.rb +123 -3
  34. data/lib/rails_lens/schema/annotation_manager.rb +24 -58
  35. data/lib/rails_lens/schema/database_annotator.rb +197 -0
  36. data/lib/rails_lens/version.rb +1 -1
  37. data/lib/rails_lens.rb +1 -1
  38. metadata +5 -1
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ module Analyzers
5
+ class Callbacks < Base
6
+ # ActiveRecord callback chains (Rails 8+ uses unified chains with kind attribute)
7
+ CALLBACK_CHAINS = %i[
8
+ validation
9
+ save
10
+ create
11
+ update
12
+ destroy
13
+ commit
14
+ rollback
15
+ touch
16
+ initialize
17
+ find
18
+ ].freeze
19
+
20
+ # Map kinds to full callback type names
21
+ KIND_PREFIXES = {
22
+ before: 'before_',
23
+ after: 'after_',
24
+ around: 'around_'
25
+ }.freeze
26
+
27
+ # Callbacks that commonly come from ActiveRecord internals (symbol-based)
28
+ INTERNAL_CALLBACK_PREFIXES = %w[
29
+ autosave_associated_records_for_
30
+ around_save_collection_association
31
+ _ensure_no_duplicate_errors
32
+ normalize_changed_in_place_attributes
33
+ clear_transaction_record_state
34
+ remember_transaction_record_state
35
+ add_to_transaction
36
+ sync_with_transaction_state
37
+ trigger_transactional_callbacks
38
+ ].freeze
39
+
40
+ # Known callback order for formatting output
41
+ CALLBACK_ORDER = %i[
42
+ before_validation after_validation
43
+ before_save around_save after_save
44
+ before_create around_create after_create
45
+ before_update around_update after_update
46
+ before_destroy around_destroy after_destroy
47
+ before_commit around_commit after_commit
48
+ before_rollback around_rollback after_rollback
49
+ after_touch
50
+ after_initialize after_find
51
+ ].freeze
52
+
53
+ def analyze
54
+ callbacks = extract_callbacks
55
+ return nil if callbacks.empty?
56
+
57
+ format_callbacks(callbacks)
58
+ end
59
+
60
+ private
61
+
62
+ def extract_callbacks
63
+ callbacks = []
64
+
65
+ CALLBACK_CHAINS.each do |chain_name|
66
+ chain_method = "_#{chain_name}_callbacks"
67
+ next unless model_class.respond_to?(chain_method)
68
+
69
+ chain = model_class.public_send(chain_method)
70
+ next if chain.blank?
71
+
72
+ chain.each do |callback|
73
+ next if internal_callback?(callback)
74
+ next unless defined_in_model_hierarchy?(callback)
75
+
76
+ callback_info = parse_callback(callback, chain_name)
77
+ callbacks << callback_info if callback_info
78
+ end
79
+ end
80
+
81
+ # Keep order, dedupe only exact duplicates (same type, method, and options hash)
82
+ seen = Set.new
83
+ callbacks.select do |c|
84
+ key = [c[:type], c[:method], c[:options].to_a.sort]
85
+ seen.add?(key)
86
+ end
87
+ end
88
+
89
+ def internal_callback?(callback)
90
+ filter = callback.filter
91
+
92
+ case filter
93
+ when Symbol
94
+ filter_name = filter.to_s
95
+ INTERNAL_CALLBACK_PREFIXES.any? { |prefix| filter_name.start_with?(prefix) }
96
+ when Proc
97
+ # Check if proc is from Rails internals (association callbacks, dependent: :destroy)
98
+ source_location = begin
99
+ filter.source_location
100
+ rescue
101
+ nil
102
+ end
103
+ return false if source_location.nil?
104
+
105
+ # Filter out procs defined in activerecord/activesupport gems
106
+ source_file = source_location[0].to_s
107
+ source_file.include?('/activerecord') || source_file.include?('/activesupport')
108
+ else
109
+ false
110
+ end
111
+ end
112
+
113
+ def defined_in_model_hierarchy?(callback)
114
+ filter = callback.filter
115
+
116
+ case filter
117
+ when Symbol
118
+ # Check if method is defined in the model class itself
119
+ return true if model_class.instance_methods(false).include?(filter)
120
+ return true if model_class.private_instance_methods(false).include?(filter)
121
+
122
+ # Check if defined in included concerns (non-Rails modules)
123
+ model_class.included_modules.each do |mod|
124
+ next if mod.name.nil?
125
+ next if mod.name.start_with?('ActiveRecord', 'ActiveModel', 'ActiveSupport')
126
+
127
+ return true if mod.instance_methods(false).include?(filter)
128
+ return true if mod.private_instance_methods(false).include?(filter)
129
+ end
130
+
131
+ # For STI: check parent classes up to (but not including) ActiveRecord::Base
132
+ klass = model_class.superclass
133
+ while klass && klass < ActiveRecord::Base
134
+ return true if klass.instance_methods(false).include?(filter)
135
+ return true if klass.private_instance_methods(false).include?(filter)
136
+
137
+ klass = klass.superclass
138
+ end
139
+
140
+ false
141
+ when Proc
142
+ # User-defined procs - already filtered internal ones in internal_callback?
143
+ true
144
+ else
145
+ # Callback objects - assume user-defined
146
+ true
147
+ end
148
+ end
149
+
150
+ def parse_callback(callback, chain_name)
151
+ filter = callback.filter
152
+ kind = callback.kind
153
+ options = extract_options(callback)
154
+
155
+ method_name = case filter
156
+ when Symbol
157
+ filter.to_s
158
+ when Proc
159
+ 'proc'
160
+ when String
161
+ filter
162
+ else
163
+ # Callback object
164
+ filter.class.name.demodulize.underscore
165
+ end
166
+
167
+ # Build full callback type (e.g., :before + :save = :before_save)
168
+ callback_type = :"#{KIND_PREFIXES[kind]}#{chain_name}"
169
+
170
+ {
171
+ type: callback_type,
172
+ method: method_name,
173
+ kind: kind,
174
+ options: options
175
+ }
176
+ end
177
+
178
+ def extract_options(callback)
179
+ options = {}
180
+
181
+ # Extract :if condition
182
+ if callback.instance_variable_defined?(:@if) && callback.instance_variable_get(:@if).present?
183
+ conditions = callback.instance_variable_get(:@if)
184
+ formatted = format_conditions(conditions)
185
+ options[:if] = formatted if formatted.any?
186
+ end
187
+
188
+ # Extract :unless condition
189
+ if callback.instance_variable_defined?(:@unless) && callback.instance_variable_get(:@unless).present?
190
+ conditions = callback.instance_variable_get(:@unless)
191
+ formatted = format_conditions(conditions)
192
+ options[:unless] = formatted if formatted.any?
193
+ end
194
+
195
+ # Extract :on option (for validation and commit callbacks)
196
+ if callback.respond_to?(:options) && callback.options[:on]
197
+ on_value = callback.options[:on]
198
+ options[:on] = Array(on_value).map(&:to_s)
199
+ end
200
+
201
+ # Extract :prepend option
202
+ if callback.respond_to?(:options) && callback.options[:prepend]
203
+ options[:prepend] = true
204
+ end
205
+
206
+ options
207
+ end
208
+
209
+ def format_conditions(conditions)
210
+ Array(conditions).filter_map do |condition|
211
+ case condition
212
+ when Symbol
213
+ condition.to_s
214
+ when Proc
215
+ source_location = begin
216
+ condition.source_location
217
+ rescue
218
+ nil
219
+ end
220
+ if source_location.nil?
221
+ 'proc'
222
+ elsif source_location[0].to_s.match?(%r{/active(record|support|model)})
223
+ # Skip Rails internal conditionals
224
+ nil
225
+ else
226
+ 'proc'
227
+ end
228
+ when String
229
+ condition
230
+ else
231
+ class_name = begin
232
+ condition.class.name
233
+ rescue
234
+ nil
235
+ end
236
+ # Skip internal Rails callback condition classes
237
+ if class_name&.start_with?('ActiveSupport::Callbacks', 'ActiveRecord')
238
+ nil
239
+ else
240
+ class_name
241
+ end
242
+ end
243
+ end
244
+ end
245
+
246
+ def format_callbacks(callbacks)
247
+ lines = []
248
+ lines << '[callbacks]'
249
+
250
+ # Group by callback type
251
+ grouped = callbacks.group_by { |c| c[:type] }
252
+
253
+ # Output known types in order first
254
+ CALLBACK_ORDER.each do |callback_type|
255
+ type_callbacks = grouped.delete(callback_type)
256
+ next unless type_callbacks&.any?
257
+
258
+ formatted = type_callbacks.map { |c| format_single_callback(c) }
259
+ lines << "#{callback_type} = [#{formatted.join(', ')}]"
260
+ end
261
+
262
+ # Output any remaining unknown callback types (future Rails versions)
263
+ grouped.each do |callback_type, type_callbacks|
264
+ next unless type_callbacks&.any?
265
+
266
+ formatted = type_callbacks.map { |c| format_single_callback(c) }
267
+ lines << "#{callback_type} = [#{formatted.join(', ')}]"
268
+ end
269
+
270
+ lines.join("\n")
271
+ end
272
+
273
+ def format_single_callback(callback)
274
+ parts = []
275
+ parts << "method = \"#{escape_toml(callback[:method])}\""
276
+
277
+ if callback[:options][:if]&.any?
278
+ if_values = callback[:options][:if].map { |v| "\"#{escape_toml(v)}\"" }.join(', ')
279
+ parts << "if = [#{if_values}]"
280
+ end
281
+
282
+ if callback[:options][:unless]&.any?
283
+ unless_values = callback[:options][:unless].map { |v| "\"#{escape_toml(v)}\"" }.join(', ')
284
+ parts << "unless = [#{unless_values}]"
285
+ end
286
+
287
+ if callback[:options][:on]&.any?
288
+ on_values = callback[:options][:on].map { |v| "\"#{escape_toml(v)}\"" }.join(', ')
289
+ parts << "on = [#{on_values}]"
290
+ end
291
+
292
+ parts << 'prepend = true' if callback[:options][:prepend]
293
+
294
+ "{ #{parts.join(', ')} }"
295
+ end
296
+
297
+ def escape_toml(str)
298
+ str.to_s.gsub('\\', '\\\\').gsub('"', '\\"')
299
+ end
300
+ end
301
+ end
302
+ end
@@ -15,7 +15,7 @@ module RailsLens
15
15
 
16
16
  def analyze_null_constraints
17
17
  columns_needing_not_null.map do |column|
18
- "Column '#{column.name}' should probably have NOT NULL constraint"
18
+ NoteCodes.note(column.name, NoteCodes::NOT_NULL)
19
19
  end
20
20
  end
21
21
 
@@ -24,11 +24,11 @@ module RailsLens
24
24
 
25
25
  columns.each do |column|
26
26
  if column.type == :boolean && column.default.nil? && column.null
27
- notes << "Boolean column '#{column.name}' should have a default value"
27
+ notes << NoteCodes.note(column.name, NoteCodes::DEFAULT)
28
28
  end
29
29
 
30
30
  if status_column?(column) && column.default.nil?
31
- notes << "Status column '#{column.name}' should have a default value"
31
+ notes << NoteCodes.note(column.name, NoteCodes::DEFAULT)
32
32
  end
33
33
  end
34
34
 
@@ -41,17 +41,17 @@ module RailsLens
41
41
  columns.each do |column|
42
42
  # Check for float columns used for money
43
43
  if money_column?(column) && column.type == :float
44
- notes << "Column '#{column.name}' appears to store monetary values - use decimal instead of float"
44
+ notes << NoteCodes.note(column.name, NoteCodes::USE_DECIMAL)
45
45
  end
46
46
 
47
47
  # Check for string columns that should be integers
48
48
  if counter_column?(column) && column.type != :integer
49
- notes << "Counter column '#{column.name}' should be integer type, not #{column.type}"
49
+ notes << NoteCodes.note(column.name, NoteCodes::USE_INTEGER)
50
50
  end
51
51
 
52
52
  # Check for inappropriately large string columns
53
53
  if column.type == :string && column.limit.nil?
54
- notes << "String column '#{column.name}' has no length limit - consider adding one"
54
+ notes << NoteCodes.note(column.name, NoteCodes::LIMIT)
55
55
  end
56
56
  end
57
57
 
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../errors'
4
- require_relative 'error_handling'
5
-
6
3
  module RailsLens
7
4
  module Analyzers
8
5
  class CompositeKeys < Base
@@ -31,8 +28,8 @@ module RailsLens
31
28
  private
32
29
 
33
30
  def format_composite_keys(keys)
34
- lines = ['== Composite Primary Key']
35
- lines << "Primary Keys: #{keys.join(', ')}"
31
+ lines = ['[composite_pk]']
32
+ lines << "keys = [#{keys.map { |k| "\"#{k}\"" }.join(', ')}]"
36
33
  lines.join("\n")
37
34
  end
38
35
 
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../errors'
4
- require_relative 'error_handling'
5
-
6
3
  module RailsLens
7
4
  module Analyzers
8
5
  class DatabaseConstraints < Base
@@ -15,12 +12,13 @@ module RailsLens
15
12
  check_constraints = connection.check_constraints(table_name)
16
13
  return nil if check_constraints.empty?
17
14
 
18
- constraints << '== Check Constraints'
19
- check_constraints.each do |constraint|
15
+ constraints << '[check_constraints]'
16
+ formatted = check_constraints.map do |constraint|
20
17
  name = constraint.options[:name] || constraint.name
21
18
  expression = constraint.expression || constraint.options[:validate]
22
- constraints << "- #{name}: #{expression}"
19
+ "{ name = \"#{name}\", expr = \"#{expression.to_s.gsub('"', '\\"')}\" }"
23
20
  end
21
+ constraints << "constraints = [#{formatted.join(', ')}]"
24
22
 
25
23
  constraints.empty? ? nil : constraints.join("\n")
26
24
  rescue ActiveRecord::StatementInvalid => e
@@ -1,28 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../errors'
4
- require_relative 'error_handling'
5
-
6
3
  module RailsLens
7
4
  module Analyzers
8
5
  class DelegatedTypes < Base
9
6
  def analyze
10
7
  return nil unless delegated_type_model?
11
8
 
12
- lines = ['== Delegated Type']
9
+ lines = ['[delegated_type]']
13
10
 
14
11
  # Find delegated type configuration
15
12
  delegated_type_info = find_delegated_type_info
16
13
  return nil unless delegated_type_info
17
14
 
18
- lines << "Type Column: #{delegated_type_info[:type_column]}"
19
- lines << "ID Column: #{delegated_type_info[:id_column]}"
15
+ lines << "type_column = \"#{delegated_type_info[:type_column]}\""
16
+ lines << "id_column = \"#{delegated_type_info[:id_column]}\""
20
17
  types_list = if delegated_type_info[:types].respond_to?(:keys)
21
18
  delegated_type_info[:types].keys
22
19
  else
23
20
  Array(delegated_type_info[:types])
24
21
  end
25
- lines << "Types: #{types_list.join(', ')}"
22
+ lines << "types = [#{types_list.map { |t| "\"#{t}\"" }.join(', ')}]"
26
23
 
27
24
  lines.join("\n")
28
25
  end
@@ -7,24 +7,18 @@ module RailsLens
7
7
  return nil unless model_class.respond_to?(:defined_enums) && model_class.defined_enums.any?
8
8
 
9
9
  lines = []
10
- lines << '== Enums'
10
+ lines << '[enums]'
11
11
 
12
12
  model_class.defined_enums.each do |name, values|
13
- # Detect if it's using integer or string values
13
+ # Format as TOML inline table: name = { key = "value", ... }
14
14
  formatted_values = if values.values.all? { |v| v.is_a?(Integer) }
15
15
  # Integer-based enum
16
- values.map { |k, v| "#{k}: #{v}" }.join(', ')
16
+ values.map { |k, v| "#{k} = #{v}" }.join(', ')
17
17
  else
18
18
  # String-based enum
19
- values.map { |k, v| "#{k}: \"#{v}\"" }.join(', ')
19
+ values.map { |k, v| "#{k} = \"#{v}\"" }.join(', ')
20
20
  end
21
- lines << "- #{name}: { #{formatted_values} }"
22
-
23
- # Add column type if we can detect it
24
- if model_class.table_exists? && model_class.columns_hash[name.to_s]
25
- column = model_class.columns_hash[name.to_s]
26
- lines.last << " (#{column.type})"
27
- end
21
+ lines << "#{name} = { #{formatted_values} }"
28
22
  end
29
23
 
30
24
  lines.join("\n")
@@ -10,13 +10,13 @@ module RailsLens
10
10
  existing_foreign_keys = connection.foreign_keys(table_name)
11
11
 
12
12
  belongs_to_associations.each do |association|
13
- next if association.polymorphic? # Can't have FK constraints on polymorphic associations
13
+ next if association.polymorphic?
14
14
 
15
15
  foreign_key = association.foreign_key
16
16
  referenced_table = association.klass.table_name
17
17
 
18
18
  unless foreign_key_exists?(foreign_key, referenced_table, existing_foreign_keys)
19
- notes << "Missing foreign key constraint on '#{foreign_key}' referencing '#{referenced_table}'"
19
+ notes << NoteCodes.note(foreign_key, NoteCodes::FK_CONSTRAINT)
20
20
  end
21
21
  end
22
22
 
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../errors'
4
- require_relative 'error_handling'
5
-
6
3
  module RailsLens
7
4
  module Analyzers
8
5
  class GeneratedColumns < Base
@@ -12,10 +9,11 @@ module RailsLens
12
9
  generated_columns = detect_generated_columns
13
10
  return nil if generated_columns.empty?
14
11
 
15
- lines = ['== Generated Columns']
16
- generated_columns.each do |column|
17
- lines << "- #{column[:name]} (#{column[:expression]})"
12
+ lines = ['[generated_columns]']
13
+ formatted = generated_columns.map do |column|
14
+ "{ name = \"#{column[:name]}\", expr = \"#{column[:expression].to_s.gsub('"', '\\"')}\" }"
18
15
  end
16
+ lines << "columns = [#{formatted.join(', ')}]"
19
17
 
20
18
  lines.join("\n")
21
19
  end
@@ -20,7 +20,7 @@ module RailsLens
20
20
  foreign_key_columns.each do |column|
21
21
  next if indexed?(column)
22
22
 
23
- notes << "Missing index on foreign key '#{column}'"
23
+ notes << NoteCodes.note(column, NoteCodes::INDEX)
24
24
  end
25
25
 
26
26
  # Check for missing indexes on polymorphic associations
@@ -29,7 +29,7 @@ module RailsLens
29
29
  id_column = "#{assoc.name}_id"
30
30
 
31
31
  unless composite_index_exists?([type_column, id_column])
32
- notes << "Missing composite index on polymorphic association '#{assoc.name}' columns [#{type_column}, #{id_column}]"
32
+ notes << NoteCodes.note(assoc.name.to_s, NoteCodes::POLY_INDEX)
33
33
  end
34
34
  end
35
35
 
@@ -43,7 +43,7 @@ module RailsLens
43
43
  indexes.each_with_index do |index, i|
44
44
  indexes[(i + 1)..].each do |other_index|
45
45
  if index_redundant?(index, other_index)
46
- notes << "Index '#{index.name}' might be redundant with '#{other_index.name}'"
46
+ notes << NoteCodes.note(index.name, NoteCodes::REDUND_IDX)
47
47
  end
48
48
  end
49
49
  end
@@ -54,7 +54,6 @@ module RailsLens
54
54
  def analyze_composite_indexes
55
55
  notes = []
56
56
 
57
- # Check for common query patterns that could benefit from composite indexes
58
57
  association_pairs = model_class.reflect_on_all_associations(:belongs_to)
59
58
  .combination(2)
60
59
  .select { |a, b| common_query_pattern?(a, b) }
@@ -62,7 +61,7 @@ module RailsLens
62
61
  association_pairs.each do |assoc1, assoc2|
63
62
  columns = [assoc1.foreign_key, assoc2.foreign_key].sort
64
63
  unless composite_index_exists?(columns)
65
- notes << "Consider composite index on [#{columns.join(', ')}] for common query pattern"
64
+ notes << NoteCodes.note(columns.join('+'), NoteCodes::COMP_INDEX)
66
65
  end
67
66
  end
68
67
 
@@ -93,7 +92,6 @@ module RailsLens
93
92
  end
94
93
 
95
94
  def index_redundant?(index1, index2)
96
- # An index is redundant if it's a prefix of another index
97
95
  return false if index1.unique != index2.unique
98
96
 
99
97
  if index1.columns.length < index2.columns.length
@@ -104,15 +102,11 @@ module RailsLens
104
102
  end
105
103
 
106
104
  def common_query_pattern?(assoc1, assoc2)
107
- # This is a simplified heuristic - in a real app, you might analyze actual queries
108
- # For now, we'll assume associations to the same model or related models are commonly queried together
109
105
  assoc1.class_name == assoc2.class_name ||
110
106
  related_models?(assoc1.class_name, assoc2.class_name)
111
107
  end
112
108
 
113
109
  def related_models?(class1, class2)
114
- # Simple heuristic: models are related if they share a common prefix
115
- # e.g., "UserProfile" and "UserSettings" are likely related
116
110
  class1.split('::').last[/^[A-Z][a-z]+/] == class2.split('::').last[/^[A-Z][a-z]+/]
117
111
  end
118
112