rails_lens 0.2.12 → 0.5.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 +32 -0
- data/README.md +88 -72
- data/lib/rails_lens/analyzers/association_analyzer.rb +3 -10
- data/lib/rails_lens/analyzers/best_practices_analyzer.rb +11 -36
- data/lib/rails_lens/analyzers/callbacks.rb +302 -0
- data/lib/rails_lens/analyzers/column_analyzer.rb +6 -6
- data/lib/rails_lens/analyzers/composite_keys.rb +2 -5
- data/lib/rails_lens/analyzers/database_constraints.rb +4 -6
- data/lib/rails_lens/analyzers/delegated_types.rb +4 -7
- data/lib/rails_lens/analyzers/enums.rb +5 -11
- data/lib/rails_lens/analyzers/foreign_key_analyzer.rb +2 -2
- data/lib/rails_lens/analyzers/generated_columns.rb +4 -6
- data/lib/rails_lens/analyzers/index_analyzer.rb +4 -10
- data/lib/rails_lens/analyzers/inheritance.rb +30 -31
- data/lib/rails_lens/analyzers/notes.rb +29 -39
- data/lib/rails_lens/analyzers/performance_analyzer.rb +3 -26
- data/lib/rails_lens/annotation_pipeline.rb +1 -0
- data/lib/rails_lens/cli.rb +1 -0
- data/lib/rails_lens/commands.rb +26 -4
- data/lib/rails_lens/configuration.rb +10 -2
- data/lib/rails_lens/erd/visualizer.rb +0 -1
- data/lib/rails_lens/extension_loader.rb +5 -4
- data/lib/rails_lens/extensions/closure_tree_ext.rb +11 -11
- data/lib/rails_lens/mailer/annotator.rb +3 -3
- data/lib/rails_lens/model_detector.rb +49 -3
- data/lib/rails_lens/model_source.rb +72 -0
- data/lib/rails_lens/model_source_loader.rb +117 -0
- data/lib/rails_lens/model_sources/active_record_source.rb +89 -0
- data/lib/rails_lens/note_codes.rb +59 -0
- data/lib/rails_lens/providers/callbacks_provider.rb +24 -0
- data/lib/rails_lens/providers/extensions_provider.rb +1 -1
- data/lib/rails_lens/providers/view_provider.rb +6 -20
- data/lib/rails_lens/schema/adapters/base.rb +39 -2
- data/lib/rails_lens/schema/adapters/database_info.rb +11 -17
- data/lib/rails_lens/schema/adapters/mysql.rb +75 -0
- data/lib/rails_lens/schema/adapters/postgresql.rb +123 -3
- data/lib/rails_lens/schema/annotation_manager.rb +105 -50
- data/lib/rails_lens/schema/database_annotator.rb +197 -0
- data/lib/rails_lens/tasks/annotate.rake +42 -1
- data/lib/rails_lens/version.rb +1 -1
- data/lib/rails_lens.rb +1 -1
- metadata +8 -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
|
-
|
|
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 <<
|
|
27
|
+
notes << NoteCodes.note(column.name, NoteCodes::DEFAULT)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
if status_column?(column) && column.default.nil?
|
|
31
|
-
notes <<
|
|
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 <<
|
|
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 <<
|
|
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 <<
|
|
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 = ['
|
|
35
|
-
lines << "
|
|
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 << '
|
|
19
|
-
check_constraints.
|
|
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
|
-
|
|
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 = ['
|
|
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 << "
|
|
19
|
-
lines << "
|
|
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 << "
|
|
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 << '
|
|
10
|
+
lines << '[enums]'
|
|
11
11
|
|
|
12
12
|
model_class.defined_enums.each do |name, values|
|
|
13
|
-
#
|
|
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}
|
|
16
|
+
values.map { |k, v| "#{k} = #{v}" }.join(', ')
|
|
17
17
|
else
|
|
18
18
|
# String-based enum
|
|
19
|
-
values.map { |k, v| "#{k}
|
|
19
|
+
values.map { |k, v| "#{k} = \"#{v}\"" }.join(', ')
|
|
20
20
|
end
|
|
21
|
-
lines << "
|
|
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?
|
|
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 <<
|
|
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 = ['
|
|
16
|
-
generated_columns.
|
|
17
|
-
|
|
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 <<
|
|
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 <<
|
|
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 <<
|
|
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 <<
|
|
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
|
|