rails_lens 0.0.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/CHANGELOG.md +8 -0
- data/LICENSE.txt +2 -2
- data/README.md +393 -9
- data/exe/rails_lens +25 -0
- data/lib/rails_lens/analyzers/association_analyzer.rb +111 -0
- data/lib/rails_lens/analyzers/base.rb +35 -0
- data/lib/rails_lens/analyzers/best_practices_analyzer.rb +114 -0
- data/lib/rails_lens/analyzers/column_analyzer.rb +97 -0
- data/lib/rails_lens/analyzers/composite_keys.rb +62 -0
- data/lib/rails_lens/analyzers/database_constraints.rb +35 -0
- data/lib/rails_lens/analyzers/delegated_types.rb +124 -0
- data/lib/rails_lens/analyzers/enums.rb +34 -0
- data/lib/rails_lens/analyzers/error_handling.rb +66 -0
- data/lib/rails_lens/analyzers/foreign_key_analyzer.rb +47 -0
- data/lib/rails_lens/analyzers/generated_columns.rb +56 -0
- data/lib/rails_lens/analyzers/index_analyzer.rb +128 -0
- data/lib/rails_lens/analyzers/inheritance.rb +212 -0
- data/lib/rails_lens/analyzers/notes.rb +325 -0
- data/lib/rails_lens/analyzers/performance_analyzer.rb +110 -0
- data/lib/rails_lens/annotation_pipeline.rb +87 -0
- data/lib/rails_lens/cli.rb +170 -0
- data/lib/rails_lens/cli_error_handler.rb +86 -0
- data/lib/rails_lens/commands.rb +164 -0
- data/lib/rails_lens/connection.rb +133 -0
- data/lib/rails_lens/erd/column_type_formatter.rb +32 -0
- data/lib/rails_lens/erd/domain_color_mapper.rb +40 -0
- data/lib/rails_lens/erd/mysql_column_type_formatter.rb +19 -0
- data/lib/rails_lens/erd/postgresql_column_type_formatter.rb +19 -0
- data/lib/rails_lens/erd/visualizer.rb +301 -0
- data/lib/rails_lens/errors.rb +78 -0
- data/lib/rails_lens/extension_loader.rb +261 -0
- data/lib/rails_lens/extensions/base.rb +194 -0
- data/lib/rails_lens/extensions/closure_tree_ext.rb +157 -0
- data/lib/rails_lens/file_insertion_helper.rb +168 -0
- data/lib/rails_lens/mailer/annotator.rb +226 -0
- data/lib/rails_lens/mailer/extractor.rb +201 -0
- data/lib/rails_lens/model_detector.rb +241 -0
- data/lib/rails_lens/parsers/class_info.rb +46 -0
- data/lib/rails_lens/parsers/module_info.rb +33 -0
- data/lib/rails_lens/parsers/parser_result.rb +55 -0
- data/lib/rails_lens/parsers/prism_parser.rb +90 -0
- data/lib/rails_lens/parsers.rb +10 -0
- data/lib/rails_lens/providers/association_notes_provider.rb +11 -0
- data/lib/rails_lens/providers/base.rb +37 -0
- data/lib/rails_lens/providers/best_practices_notes_provider.rb +11 -0
- data/lib/rails_lens/providers/column_notes_provider.rb +11 -0
- data/lib/rails_lens/providers/composite_keys_provider.rb +11 -0
- data/lib/rails_lens/providers/database_constraints_provider.rb +11 -0
- data/lib/rails_lens/providers/delegated_types_provider.rb +11 -0
- data/lib/rails_lens/providers/enums_provider.rb +11 -0
- data/lib/rails_lens/providers/extension_notes_provider.rb +20 -0
- data/lib/rails_lens/providers/extensions_provider.rb +22 -0
- data/lib/rails_lens/providers/foreign_key_notes_provider.rb +11 -0
- data/lib/rails_lens/providers/generated_columns_provider.rb +11 -0
- data/lib/rails_lens/providers/index_notes_provider.rb +20 -0
- data/lib/rails_lens/providers/inheritance_provider.rb +23 -0
- data/lib/rails_lens/providers/notes_provider_base.rb +25 -0
- data/lib/rails_lens/providers/performance_notes_provider.rb +11 -0
- data/lib/rails_lens/providers/schema_provider.rb +48 -0
- data/lib/rails_lens/providers/section_provider_base.rb +28 -0
- data/lib/rails_lens/railtie.rb +17 -0
- data/lib/rails_lens/rake_bootstrapper.rb +18 -0
- data/lib/rails_lens/route/annotator.rb +268 -0
- data/lib/rails_lens/route/extractor.rb +133 -0
- data/lib/rails_lens/route/parser.rb +59 -0
- data/lib/rails_lens/schema/adapters/base.rb +345 -0
- data/lib/rails_lens/schema/adapters/database_info.rb +118 -0
- data/lib/rails_lens/schema/adapters/mysql.rb +279 -0
- data/lib/rails_lens/schema/adapters/postgresql.rb +197 -0
- data/lib/rails_lens/schema/adapters/sqlite3.rb +96 -0
- data/lib/rails_lens/schema/annotation.rb +144 -0
- data/lib/rails_lens/schema/annotation_manager.rb +202 -0
- data/lib/rails_lens/tasks/annotate.rake +35 -0
- data/lib/rails_lens/tasks/erd.rake +24 -0
- data/lib/rails_lens/tasks/mailers.rake +27 -0
- data/lib/rails_lens/tasks/routes.rake +27 -0
- data/lib/rails_lens/tasks/schema.rake +108 -0
- data/lib/rails_lens/version.rb +5 -0
- data/lib/rails_lens.rb +138 -5
- 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
|