rails_lens 0.0.0 → 0.2.2

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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/LICENSE.txt +2 -2
  4. data/README.md +463 -9
  5. data/exe/rails_lens +25 -0
  6. data/lib/rails_lens/analyzers/association_analyzer.rb +111 -0
  7. data/lib/rails_lens/analyzers/base.rb +35 -0
  8. data/lib/rails_lens/analyzers/best_practices_analyzer.rb +114 -0
  9. data/lib/rails_lens/analyzers/column_analyzer.rb +97 -0
  10. data/lib/rails_lens/analyzers/composite_keys.rb +62 -0
  11. data/lib/rails_lens/analyzers/database_constraints.rb +35 -0
  12. data/lib/rails_lens/analyzers/delegated_types.rb +129 -0
  13. data/lib/rails_lens/analyzers/enums.rb +34 -0
  14. data/lib/rails_lens/analyzers/error_handling.rb +66 -0
  15. data/lib/rails_lens/analyzers/foreign_key_analyzer.rb +47 -0
  16. data/lib/rails_lens/analyzers/generated_columns.rb +56 -0
  17. data/lib/rails_lens/analyzers/index_analyzer.rb +128 -0
  18. data/lib/rails_lens/analyzers/inheritance.rb +212 -0
  19. data/lib/rails_lens/analyzers/notes.rb +325 -0
  20. data/lib/rails_lens/analyzers/performance_analyzer.rb +110 -0
  21. data/lib/rails_lens/annotation_pipeline.rb +87 -0
  22. data/lib/rails_lens/cli.rb +176 -0
  23. data/lib/rails_lens/cli_error_handler.rb +86 -0
  24. data/lib/rails_lens/commands.rb +164 -0
  25. data/lib/rails_lens/connection.rb +133 -0
  26. data/lib/rails_lens/erd/column_type_formatter.rb +32 -0
  27. data/lib/rails_lens/erd/domain_color_mapper.rb +40 -0
  28. data/lib/rails_lens/erd/mysql_column_type_formatter.rb +19 -0
  29. data/lib/rails_lens/erd/postgresql_column_type_formatter.rb +19 -0
  30. data/lib/rails_lens/erd/visualizer.rb +329 -0
  31. data/lib/rails_lens/errors.rb +78 -0
  32. data/lib/rails_lens/extension_loader.rb +261 -0
  33. data/lib/rails_lens/extensions/base.rb +194 -0
  34. data/lib/rails_lens/extensions/closure_tree_ext.rb +157 -0
  35. data/lib/rails_lens/file_insertion_helper.rb +168 -0
  36. data/lib/rails_lens/mailer/annotator.rb +226 -0
  37. data/lib/rails_lens/mailer/extractor.rb +201 -0
  38. data/lib/rails_lens/model_detector.rb +252 -0
  39. data/lib/rails_lens/parsers/class_info.rb +46 -0
  40. data/lib/rails_lens/parsers/module_info.rb +33 -0
  41. data/lib/rails_lens/parsers/parser_result.rb +55 -0
  42. data/lib/rails_lens/parsers/prism_parser.rb +90 -0
  43. data/lib/rails_lens/parsers.rb +10 -0
  44. data/lib/rails_lens/providers/association_notes_provider.rb +11 -0
  45. data/lib/rails_lens/providers/base.rb +37 -0
  46. data/lib/rails_lens/providers/best_practices_notes_provider.rb +11 -0
  47. data/lib/rails_lens/providers/column_notes_provider.rb +11 -0
  48. data/lib/rails_lens/providers/composite_keys_provider.rb +11 -0
  49. data/lib/rails_lens/providers/database_constraints_provider.rb +11 -0
  50. data/lib/rails_lens/providers/delegated_types_provider.rb +11 -0
  51. data/lib/rails_lens/providers/enums_provider.rb +11 -0
  52. data/lib/rails_lens/providers/extension_notes_provider.rb +20 -0
  53. data/lib/rails_lens/providers/extensions_provider.rb +22 -0
  54. data/lib/rails_lens/providers/foreign_key_notes_provider.rb +11 -0
  55. data/lib/rails_lens/providers/generated_columns_provider.rb +11 -0
  56. data/lib/rails_lens/providers/index_notes_provider.rb +20 -0
  57. data/lib/rails_lens/providers/inheritance_provider.rb +23 -0
  58. data/lib/rails_lens/providers/notes_provider_base.rb +25 -0
  59. data/lib/rails_lens/providers/performance_notes_provider.rb +11 -0
  60. data/lib/rails_lens/providers/schema_provider.rb +61 -0
  61. data/lib/rails_lens/providers/section_provider_base.rb +28 -0
  62. data/lib/rails_lens/railtie.rb +17 -0
  63. data/lib/rails_lens/rake_bootstrapper.rb +18 -0
  64. data/lib/rails_lens/route/annotator.rb +268 -0
  65. data/lib/rails_lens/route/extractor.rb +133 -0
  66. data/lib/rails_lens/route/parser.rb +59 -0
  67. data/lib/rails_lens/schema/adapters/base.rb +345 -0
  68. data/lib/rails_lens/schema/adapters/database_info.rb +118 -0
  69. data/lib/rails_lens/schema/adapters/mysql.rb +279 -0
  70. data/lib/rails_lens/schema/adapters/postgresql.rb +197 -0
  71. data/lib/rails_lens/schema/adapters/sqlite3.rb +96 -0
  72. data/lib/rails_lens/schema/annotation.rb +144 -0
  73. data/lib/rails_lens/schema/annotation_manager.rb +202 -0
  74. data/lib/rails_lens/tasks/annotate.rake +35 -0
  75. data/lib/rails_lens/tasks/erd.rake +24 -0
  76. data/lib/rails_lens/tasks/mailers.rake +27 -0
  77. data/lib/rails_lens/tasks/routes.rake +27 -0
  78. data/lib/rails_lens/tasks/schema.rake +108 -0
  79. data/lib/rails_lens/version.rb +5 -0
  80. data/lib/rails_lens.rb +138 -5
  81. metadata +215 -11
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsLens
4
+ module Analyzers
5
+ class IndexAnalyzer < Base
6
+ def analyze
7
+ notes = []
8
+ notes.concat(analyze_missing_indexes)
9
+ notes.concat(analyze_redundant_indexes)
10
+ notes.concat(analyze_composite_indexes)
11
+ notes
12
+ end
13
+
14
+ private
15
+
16
+ def analyze_missing_indexes
17
+ notes = []
18
+
19
+ # Check for missing indexes on foreign keys
20
+ foreign_key_columns.each do |column|
21
+ next if indexed?(column)
22
+
23
+ notes << "Missing index on foreign key '#{column}'"
24
+ end
25
+
26
+ # Check for missing indexes on polymorphic associations
27
+ polymorphic_associations.each do |assoc|
28
+ type_column = "#{assoc.name}_type"
29
+ id_column = "#{assoc.name}_id"
30
+
31
+ unless composite_index_exists?([type_column, id_column])
32
+ notes << "Missing composite index on polymorphic association '#{assoc.name}' columns [#{type_column}, #{id_column}]"
33
+ end
34
+ end
35
+
36
+ notes
37
+ end
38
+
39
+ def analyze_redundant_indexes
40
+ notes = []
41
+ indexes = connection.indexes(table_name)
42
+
43
+ indexes.each_with_index do |index, i|
44
+ indexes[(i + 1)..].each do |other_index|
45
+ if index_redundant?(index, other_index)
46
+ notes << "Index '#{index.name}' might be redundant with '#{other_index.name}'"
47
+ end
48
+ end
49
+ end
50
+
51
+ notes
52
+ end
53
+
54
+ def analyze_composite_indexes
55
+ notes = []
56
+
57
+ # Check for common query patterns that could benefit from composite indexes
58
+ association_pairs = model_class.reflect_on_all_associations(:belongs_to)
59
+ .combination(2)
60
+ .select { |a, b| common_query_pattern?(a, b) }
61
+
62
+ association_pairs.each do |assoc1, assoc2|
63
+ columns = [assoc1.foreign_key, assoc2.foreign_key].sort
64
+ unless composite_index_exists?(columns)
65
+ notes << "Consider composite index on [#{columns.join(', ')}] for common query pattern"
66
+ end
67
+ end
68
+
69
+ notes
70
+ end
71
+
72
+ def foreign_key_columns
73
+ model_class.reflect_on_all_associations(:belongs_to)
74
+ .reject(&:polymorphic?)
75
+ .map(&:foreign_key)
76
+ end
77
+
78
+ def polymorphic_associations
79
+ model_class.reflect_on_all_associations(:belongs_to)
80
+ .select(&:polymorphic?)
81
+ end
82
+
83
+ def indexed?(column)
84
+ connection.indexes(table_name).any? do |index|
85
+ index.columns.include?(column.to_s)
86
+ end
87
+ end
88
+
89
+ def composite_index_exists?(columns)
90
+ connection.indexes(table_name).any? do |index|
91
+ index.columns == columns.map(&:to_s)
92
+ end
93
+ end
94
+
95
+ def index_redundant?(index1, index2)
96
+ # An index is redundant if it's a prefix of another index
97
+ return false if index1.unique != index2.unique
98
+
99
+ if index1.columns.length < index2.columns.length
100
+ index2.columns[0...index1.columns.length] == index1.columns
101
+ else
102
+ index1.columns[0...index2.columns.length] == index2.columns
103
+ end
104
+ end
105
+
106
+ 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
+ assoc1.class_name == assoc2.class_name ||
110
+ related_models?(assoc1.class_name, assoc2.class_name)
111
+ end
112
+
113
+ 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
+ class1.split('::').last[/^[A-Z][a-z]+/] == class2.split('::').last[/^[A-Z][a-z]+/]
117
+ end
118
+
119
+ def connection
120
+ model_class.connection
121
+ end
122
+
123
+ def table_name
124
+ model_class.table_name
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+ require_relative 'error_handling'
5
+
6
+ module RailsLens
7
+ module Analyzers
8
+ class Inheritance < Base
9
+ def analyze
10
+ results = []
11
+
12
+ results << analyze_sti if sti_model?
13
+ results << analyze_delegated_type if delegated_type_model?
14
+ results << analyze_polymorphic if polymorphic_associations?
15
+
16
+ return nil if results.empty?
17
+
18
+ results.compact.join("\n\n")
19
+ end
20
+
21
+ private
22
+
23
+ def sti_model?
24
+ return false if model_class.abstract_class?
25
+
26
+ # Check if this model uses STI
27
+ model_class.inheritance_column &&
28
+ model_class.column_names.include?(model_class.inheritance_column)
29
+ end
30
+
31
+ def delegated_type_model?
32
+ # Check for delegated_type declaration
33
+ model_class.respond_to?(:delegated_type_reflection) &&
34
+ model_class.delegated_type_reflection.present?
35
+ rescue NoMethodError => e
36
+ Rails.logger.debug { "Failed to check delegated type for #{model_class.name}: #{e.message}" }
37
+ false
38
+ rescue NameError => e
39
+ Rails.logger.debug { "Name error checking delegated type: #{e.message}" }
40
+ false
41
+ end
42
+
43
+ def analyze_sti
44
+ lines = []
45
+ lines << '== Inheritance (STI)'
46
+ lines << "Type Column: #{model_class.inheritance_column}"
47
+
48
+ # Check if this is a base class or subclass
49
+ if model_class.base_class == model_class
50
+ # This is the base class
51
+ subclasses = find_sti_subclasses
52
+ lines << "Known Subclasses: #{subclasses.join(', ')}" if subclasses.any?
53
+ lines << 'Base Class: Yes'
54
+ else
55
+ # This is a subclass
56
+ lines << "Base Class: #{model_class.base_class.name}"
57
+ lines << "Type Value: #{model_class.sti_name}"
58
+
59
+ # Find siblings
60
+ siblings = find_sti_siblings
61
+ lines << "Sibling Classes: #{siblings.join(', ')}" if siblings.any?
62
+ end
63
+
64
+ lines.join("\n")
65
+ end
66
+
67
+ def analyze_delegated_type
68
+ reflection = model_class.delegated_type_reflection
69
+ return nil unless reflection
70
+
71
+ lines = []
72
+ lines << '== Delegated Type'
73
+ lines << "Delegate: #{reflection.name}"
74
+ lines << "Type Column: #{reflection.foreign_type}"
75
+ lines << "ID Column: #{reflection.foreign_key}"
76
+
77
+ # Try to find known types
78
+ types = find_delegated_types(reflection)
79
+ lines << "Known Types: #{types.join(', ')}" if types.any?
80
+
81
+ lines.join("\n")
82
+ end
83
+
84
+ def find_sti_subclasses
85
+ # Find all direct subclasses
86
+ subclasses = []
87
+
88
+ ObjectSpace.each_object(Class) do |klass|
89
+ subclasses << klass.name if klass < model_class && klass != model_class && klass.base_class == model_class
90
+ end
91
+
92
+ subclasses.sort
93
+ rescue NoMethodError => e
94
+ Rails.logger.debug { "Failed to find STI subclasses for #{model_class.name}: #{e.message}" }
95
+ []
96
+ rescue NameError => e
97
+ Rails.logger.debug { "Name error finding STI subclasses: #{e.message}" }
98
+ []
99
+ end
100
+
101
+ def find_sti_siblings
102
+ return [] unless model_class.base_class != model_class
103
+
104
+ siblings = []
105
+ base = model_class.base_class
106
+
107
+ ObjectSpace.each_object(Class) do |klass|
108
+ siblings << klass.name if klass < base && klass != model_class && klass.base_class == base
109
+ end
110
+
111
+ siblings.sort
112
+ rescue NoMethodError => e
113
+ Rails.logger.debug { "Failed to find STI siblings for #{model_class.name}: #{e.message}" }
114
+ []
115
+ rescue NameError => e
116
+ Rails.logger.debug { "Name error finding STI siblings: #{e.message}" }
117
+ []
118
+ end
119
+
120
+ def find_delegated_types(reflection)
121
+ # Try to find models that could be delegated types
122
+ types = []
123
+ type_column = reflection.foreign_type
124
+
125
+ # Look for records in the database to see what types exist
126
+ if model_class.table_exists?
127
+ existing_types = model_class
128
+ .where.not(type_column => nil)
129
+ .distinct
130
+ .pluck(type_column)
131
+ .compact
132
+ .sort
133
+
134
+ types.concat(existing_types)
135
+ end
136
+
137
+ types.uniq.sort
138
+ rescue ActiveRecord::StatementInvalid => e
139
+ Rails.logger.debug { "Database error finding delegated types for #{model_class.name}: #{e.message}" }
140
+ []
141
+ rescue ActiveRecord::ConnectionNotEstablished => e
142
+ Rails.logger.debug { "No database connection for #{model_class.name}: #{e.message}" }
143
+ []
144
+ end
145
+
146
+ def polymorphic_associations?
147
+ model_class.reflect_on_all_associations.any? do |reflection|
148
+ reflection.options[:polymorphic] || reflection.options[:as]
149
+ end
150
+ end
151
+
152
+ def analyze_polymorphic
153
+ lines = []
154
+ lines << '== Polymorphic Associations'
155
+
156
+ # Find polymorphic belongs_to associations
157
+ polymorphic_belongs_to = model_class.reflect_on_all_associations(:belongs_to).select do |r|
158
+ r.options[:polymorphic]
159
+ end
160
+
161
+ if polymorphic_belongs_to.any?
162
+ lines << 'Polymorphic References:'
163
+ polymorphic_belongs_to.each do |reflection|
164
+ lines << "- #{reflection.name} (#{reflection.foreign_type}/#{reflection.foreign_key})"
165
+
166
+ # Try to find what types are actually used
167
+ next unless model_class.table_exists? && model_class.columns_hash[reflection.foreign_type.to_s]
168
+
169
+ types = find_polymorphic_types(reflection)
170
+ lines << " Types: #{types.join(', ')}" if types.any?
171
+ end
172
+ end
173
+
174
+ # Find associations that reference this model polymorphically
175
+ polymorphic_has_many = model_class.reflect_on_all_associations.select do |r|
176
+ r.options[:as]
177
+ end
178
+
179
+ if polymorphic_has_many.any?
180
+ lines << '' if polymorphic_belongs_to.any?
181
+ lines << 'Polymorphic Targets:'
182
+ polymorphic_has_many.each do |reflection|
183
+ as_name = reflection.options[:as]
184
+ lines << "- #{reflection.name} (as: :#{as_name})"
185
+ end
186
+ end
187
+
188
+ return nil if lines.size == 1 # Only header
189
+
190
+ lines.join("\n")
191
+ end
192
+
193
+ def find_polymorphic_types(reflection)
194
+ return [] unless model_class.table_exists?
195
+
196
+ type_column = reflection.foreign_type
197
+ model_class
198
+ .where.not(type_column => nil)
199
+ .distinct
200
+ .pluck(type_column)
201
+ .compact
202
+ .sort
203
+ rescue ActiveRecord::StatementInvalid => e
204
+ Rails.logger.debug { "Database error finding polymorphic types for #{model_class.name}: #{e.message}" }
205
+ []
206
+ rescue ActiveRecord::ConnectionNotEstablished => e
207
+ Rails.logger.debug { "No database connection for #{model_class.name}: #{e.message}" }
208
+ []
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+ require_relative 'error_handling'
5
+
6
+ module RailsLens
7
+ module Analyzers
8
+ class Notes < Base
9
+ def initialize(model_class)
10
+ super
11
+ @connection = model_class.connection
12
+ @table_name = model_class.table_name
13
+ rescue ActiveRecord::ConnectionNotEstablished => e
14
+ Rails.logger.debug { "No database connection for #{model_class.name}: #{e.message}" }
15
+ @connection = nil
16
+ @table_name = nil
17
+ rescue NoMethodError => e
18
+ Rails.logger.debug { "Failed to initialize Notes analyzer for #{model_class.name}: #{e.message}" }
19
+ @connection = nil
20
+ @table_name = nil
21
+ rescue RuntimeError => e
22
+ Rails.logger.debug { "Runtime error initializing Notes analyzer for #{model_class.name}: #{e.message}" }
23
+ @connection = nil
24
+ @table_name = nil
25
+ end
26
+
27
+ def analyze
28
+ return nil unless @connection && @table_name
29
+
30
+ notes = []
31
+
32
+ notes.concat(analyze_indexes)
33
+ notes.concat(analyze_foreign_keys)
34
+ notes.concat(analyze_associations)
35
+ notes.concat(analyze_columns)
36
+ notes.concat(analyze_performance)
37
+ notes.concat(analyze_best_practices)
38
+
39
+ notes.compact.uniq
40
+ rescue ActiveRecord::StatementInvalid => e
41
+ Rails.logger.debug { "Database error analyzing notes for #{@table_name}: #{e.message}" }
42
+ nil
43
+ rescue NoMethodError => e
44
+ Rails.logger.debug { "Method error analyzing notes for #{@table_name}: #{e.message}" }
45
+ nil
46
+ end
47
+
48
+ private
49
+
50
+ def analyze_indexes
51
+ notes = []
52
+
53
+ # Check for missing indexes on foreign keys
54
+ foreign_key_columns.each do |column|
55
+ notes << "Missing index on foreign key '#{column}'" unless has_index?(column)
56
+ end
57
+
58
+ # Check for missing composite indexes
59
+ common_query_patterns.each do |columns|
60
+ unless has_composite_index?(columns)
61
+ notes << "Consider composite index on (#{columns.join(', ')}) for common queries"
62
+ end
63
+ end
64
+
65
+ # Check for redundant indexes
66
+ redundant_indexes.each do |index|
67
+ notes << "Index '#{index.name}' might be redundant"
68
+ end
69
+
70
+ notes
71
+ end
72
+
73
+ def analyze_foreign_keys
74
+ notes = []
75
+
76
+ # Check for missing foreign key constraints
77
+ belongs_to_associations.each do |association|
78
+ column = association.foreign_key
79
+ next unless column_exists?(column)
80
+
81
+ unless has_foreign_key_constraint?(column)
82
+ notes << "Missing foreign key constraint for '#{column}' (#{association.name})"
83
+ end
84
+ end
85
+
86
+ notes
87
+ end
88
+
89
+ def analyze_associations
90
+ # Check for missing inverse_of
91
+ notes = associations_needing_inverse.map do |association|
92
+ "Association '#{association.name}' should specify inverse_of"
93
+ end
94
+
95
+ # Check for N+1 query risks
96
+ has_many_associations.each do |association|
97
+ if likely_n_plus_one?(association)
98
+ notes << "Association '#{association.name}' has N+1 query risk - consider includes/preload"
99
+ end
100
+ end
101
+
102
+ # Check for missing counter caches
103
+ associations_needing_counter_cache.each do |association|
104
+ notes << "Consider adding counter cache for '#{association.name}'"
105
+ end
106
+
107
+ notes
108
+ end
109
+
110
+ def analyze_columns
111
+ # Check for missing NOT NULL constraints
112
+ notes = columns_needing_not_null.map do |column|
113
+ "Column '#{column.name}' should probably have NOT NULL constraint"
114
+ end
115
+
116
+ # Check for missing defaults
117
+ columns_needing_defaults.each do |column|
118
+ notes << "Column '#{column.name}' should have a default value"
119
+ end
120
+
121
+ # Check for inappropriate column types
122
+ columns.each do |column|
123
+ if column.name.end_with?('_count') && column.type != :integer
124
+ notes << "Counter column '#{column.name}' should be integer type"
125
+ end
126
+
127
+ if column.name.match?(/price|amount|cost/) && column.type == :float
128
+ notes << "Monetary column '#{column.name}' should use decimal type, not float"
129
+ end
130
+ end
131
+
132
+ notes
133
+ end
134
+
135
+ def analyze_performance
136
+ # Large text columns without separate storage
137
+ notes = large_text_columns.map do |column|
138
+ "Large text column '#{column.name}' might benefit from separate storage"
139
+ end
140
+
141
+ # Polymorphic associations without indexes
142
+ polymorphic_associations.each do |association|
143
+ # For polymorphic belongs_to associations
144
+ next unless association.macro == :belongs_to && association.polymorphic?
145
+
146
+ foreign_key = association.foreign_key.to_s
147
+ type_column = "#{association.foreign_type || association.name}_type"
148
+ unless has_composite_index?([foreign_key, type_column])
149
+ notes << "Polymorphic association '#{association.name}' needs composite index on (#{foreign_key}, #{type_column})"
150
+ end
151
+ end
152
+
153
+ # UUID columns without proper indexes
154
+ uuid_columns.each do |column|
155
+ if column.name.end_with?('_id') && !has_index?(column.name)
156
+ notes << "UUID column '#{column.name}' needs an index"
157
+ end
158
+ end
159
+
160
+ notes
161
+ end
162
+
163
+ def analyze_best_practices
164
+ notes = []
165
+
166
+ # Check for updated_at/created_at
167
+ notes << "Missing 'created_at' timestamp column" unless column_exists?('created_at')
168
+
169
+ notes << "Missing 'updated_at' timestamp column" unless column_exists?('updated_at')
170
+
171
+ # Check for soft deletes without index
172
+ if column_exists?('deleted_at') && !has_index?('deleted_at')
173
+ notes << "Soft delete column 'deleted_at' needs an index"
174
+ end
175
+
176
+ # Check for STI without index
177
+ if model_class.inheritance_column && column_exists?(model_class.inheritance_column) && !has_index?(model_class.inheritance_column)
178
+ notes << "STI column '#{model_class.inheritance_column}' needs an index"
179
+ end
180
+
181
+ notes
182
+ end
183
+
184
+ # Helper methods
185
+
186
+ def columns
187
+ @columns ||= connection.columns(table_name)
188
+ end
189
+
190
+ def indexes
191
+ @indexes ||= connection.indexes(table_name)
192
+ end
193
+
194
+ def foreign_keys
195
+ @foreign_keys ||= if connection.respond_to?(:foreign_keys)
196
+ connection.foreign_keys(table_name)
197
+ else
198
+ []
199
+ end
200
+ end
201
+
202
+ def associations
203
+ @associations ||= model_class.reflect_on_all_associations
204
+ end
205
+
206
+ def belongs_to_associations
207
+ associations.select { |a| a.macro == :belongs_to }
208
+ end
209
+
210
+ def has_many_associations
211
+ associations.select { |a| a.macro == :has_many }
212
+ end
213
+
214
+ def polymorphic_associations
215
+ associations.select(&:polymorphic?)
216
+ end
217
+
218
+ def column_exists?(column_name)
219
+ columns.any? { |c| c.name == column_name.to_s }
220
+ end
221
+
222
+ def has_index?(column_name)
223
+ indexes.any? { |index| index.columns.include?(column_name.to_s) }
224
+ end
225
+
226
+ def has_composite_index?(column_names)
227
+ indexes.any? { |index| index.columns == column_names.map(&:to_s) }
228
+ end
229
+
230
+ def has_foreign_key_constraint?(column_name)
231
+ foreign_keys.any? { |fk| fk.column == column_name.to_s }
232
+ end
233
+
234
+ def foreign_key_columns
235
+ columns.select { |c| c.name.end_with?('_id') }.map(&:name)
236
+ end
237
+
238
+ def common_query_patterns
239
+ patterns = []
240
+
241
+ # Common patterns for most models
242
+ patterns << %w[user_id created_at] if column_exists?('created_at') && column_exists?('user_id')
243
+
244
+ patterns << %w[status created_at] if column_exists?('status') && column_exists?('created_at')
245
+
246
+ patterns
247
+ end
248
+
249
+ def redundant_indexes
250
+ redundant = []
251
+
252
+ indexes.each do |index|
253
+ indexes.each do |other_index|
254
+ next if index.name == other_index.name
255
+
256
+ # Check if index is a prefix of other_index
257
+ redundant << index if other_index.columns[0...index.columns.length] == index.columns
258
+ end
259
+ end
260
+
261
+ redundant.uniq
262
+ end
263
+
264
+ def associations_needing_inverse
265
+ associations.select do |association|
266
+ association.options[:inverse_of].nil? &&
267
+ !association.options[:through] &&
268
+ !association.options[:as] &&
269
+ association.macro != :has_and_belongs_to_many
270
+ end
271
+ end
272
+
273
+ def likely_n_plus_one?(association)
274
+ # Simple heuristic: has_many without conditions/scopes that would limit results
275
+ association.macro == :has_many &&
276
+ !association.options[:limit] &&
277
+ !association.scope
278
+ end
279
+
280
+ def associations_needing_counter_cache
281
+ belongs_to_associations.select do |association|
282
+ # Check if the inverse association exists and is commonly counted
283
+ inverse = association.klass.reflect_on_association(association.inverse_of&.name || model_class.name.underscore.pluralize)
284
+ inverse && inverse.macro == :has_many && !association.options[:counter_cache]
285
+ rescue NameError => e
286
+ Rails.logger.debug { "Failed to check counter cache for association: #{e.message}" }
287
+ false
288
+ rescue NoMethodError => e
289
+ Rails.logger.debug { "Method error checking counter cache: #{e.message}" }
290
+ false
291
+ end
292
+ end
293
+
294
+ def columns_needing_not_null
295
+ columns.select do |column|
296
+ column.null &&
297
+ !column.name.end_with?('_at') &&
298
+ !column.name.end_with?('_id') &&
299
+ %w[id created_at updated_at].exclude?(column.name) &&
300
+ column.type != :text &&
301
+ column.type != :json
302
+ end
303
+ end
304
+
305
+ def columns_needing_defaults
306
+ columns.select do |column|
307
+ column.default.nil? &&
308
+ column.type == :boolean
309
+ end
310
+ end
311
+
312
+ def large_text_columns
313
+ columns.select do |column|
314
+ %i[text json jsonb].include?(column.type)
315
+ end
316
+ end
317
+
318
+ def uuid_columns
319
+ columns.select do |column|
320
+ column.type == :uuid || (column.type == :string && column.name.match?(/uuid|guid/))
321
+ end
322
+ end
323
+ end
324
+ end
325
+ end