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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/LICENSE.txt +2 -2
- data/README.md +463 -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 +129 -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 +176 -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 +329 -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 +252 -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 +61 -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,261 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
class ExtensionLoader
|
5
|
+
class << self
|
6
|
+
def load_extensions
|
7
|
+
return [] unless extensions_enabled?
|
8
|
+
|
9
|
+
extensions = []
|
10
|
+
|
11
|
+
# Load built-in extensions
|
12
|
+
extensions.concat(load_builtin_extensions)
|
13
|
+
|
14
|
+
# Load gem-provided extensions
|
15
|
+
extensions.concat(load_gem_extensions) if autoload_enabled?
|
16
|
+
|
17
|
+
# Load custom extensions
|
18
|
+
extensions.concat(load_custom_extensions)
|
19
|
+
|
20
|
+
# Filter out ignored extensions
|
21
|
+
extensions.reject { |ext| ignored?(ext) }
|
22
|
+
end
|
23
|
+
|
24
|
+
def find_extension_for(_model_class)
|
25
|
+
load_extensions.find do |extension_class|
|
26
|
+
extension_class.detect? && extension_class.compatible?
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def apply_extensions(model_class)
|
31
|
+
results = {
|
32
|
+
annotations: [],
|
33
|
+
notes: [],
|
34
|
+
erd_additions: {
|
35
|
+
relationships: [],
|
36
|
+
badges: [],
|
37
|
+
attributes: {}
|
38
|
+
}
|
39
|
+
}
|
40
|
+
|
41
|
+
load_extensions.each do |extension_class|
|
42
|
+
next unless extension_class.detect?
|
43
|
+
next unless extension_class.compatible?
|
44
|
+
|
45
|
+
extension = extension_class.new(model_class)
|
46
|
+
|
47
|
+
# Collect annotations with individual error handling
|
48
|
+
begin
|
49
|
+
if (annotation = extension.annotate)
|
50
|
+
results[:annotations] << annotation
|
51
|
+
end
|
52
|
+
rescue StandardError => e
|
53
|
+
log_extension_method_error(extension_class, 'annotate', e, model_class)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Collect notes with individual error handling
|
57
|
+
begin
|
58
|
+
notes = extension.notes
|
59
|
+
results[:notes].concat(notes) if notes.is_a?(Array)
|
60
|
+
rescue StandardError => e
|
61
|
+
log_extension_method_error(extension_class, 'notes', e, model_class)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Collect ERD additions with individual error handling
|
65
|
+
begin
|
66
|
+
erd = extension.erd_additions
|
67
|
+
if erd.is_a?(Hash)
|
68
|
+
results[:erd_additions][:relationships].concat(erd[:relationships] || [])
|
69
|
+
results[:erd_additions][:badges].concat(erd[:badges] || [])
|
70
|
+
results[:erd_additions][:attributes].merge!(erd[:attributes] || {})
|
71
|
+
end
|
72
|
+
rescue StandardError => e
|
73
|
+
log_extension_method_error(extension_class, 'erd_additions', e, model_class)
|
74
|
+
end
|
75
|
+
rescue StandardError => e
|
76
|
+
log_extension_error("Failed to initialize or detect extension #{extension_class}: #{e.message}",
|
77
|
+
extension_class.gem_name)
|
78
|
+
end
|
79
|
+
|
80
|
+
results
|
81
|
+
end
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def extensions_enabled?
|
86
|
+
extensions_config = RailsLens.config.extensions
|
87
|
+
extensions_config && extensions_config[:enabled]
|
88
|
+
end
|
89
|
+
|
90
|
+
def autoload_enabled?
|
91
|
+
extensions_config = RailsLens.config.extensions
|
92
|
+
extensions_config && extensions_config[:autoload]
|
93
|
+
end
|
94
|
+
|
95
|
+
def ignored?(extension_class)
|
96
|
+
extensions_config = RailsLens.config.extensions
|
97
|
+
ignored_gems = extensions_config ? extensions_config[:ignore] : []
|
98
|
+
ignored_gems.include?(extension_class.gem_name)
|
99
|
+
end
|
100
|
+
|
101
|
+
def load_builtin_extensions
|
102
|
+
extensions = []
|
103
|
+
|
104
|
+
# Load all Ruby files in the extensions directory
|
105
|
+
Dir[File.join(__dir__, 'extensions', '*.rb')].each do |file|
|
106
|
+
next if file.end_with?('base.rb') # Skip the base class
|
107
|
+
|
108
|
+
begin
|
109
|
+
require file
|
110
|
+
|
111
|
+
# Find the extension class
|
112
|
+
basename = File.basename(file, '.rb')
|
113
|
+
class_name = basename.split('_').map(&:capitalize).join
|
114
|
+
|
115
|
+
if RailsLens::Extensions.const_defined?(class_name)
|
116
|
+
extension_class = RailsLens::Extensions.const_get(class_name)
|
117
|
+
if valid_extension?(extension_class)
|
118
|
+
extensions << extension_class
|
119
|
+
else
|
120
|
+
log_extension_error("Builtin extension #{class_name} failed validation", file)
|
121
|
+
end
|
122
|
+
else
|
123
|
+
log_extension_error("Expected class #{class_name} not found", file)
|
124
|
+
end
|
125
|
+
rescue LoadError, SyntaxError => e
|
126
|
+
log_extension_error("Failed to load extension file: #{e.message}", file)
|
127
|
+
rescue StandardError => e
|
128
|
+
log_extension_error("Unexpected error loading extension: #{e.message}", file)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
extensions
|
133
|
+
end
|
134
|
+
|
135
|
+
def load_gem_extensions
|
136
|
+
extensions = []
|
137
|
+
|
138
|
+
# Check each loaded gem for RailsLens extensions
|
139
|
+
|
140
|
+
Gem.loaded_specs.each_key do |gem_name|
|
141
|
+
# Try to find extension in the gem
|
142
|
+
gem_constant_name = gem_name.gsub('-', '_').split('_').map(&:capitalize).join
|
143
|
+
extension_constant_name = "#{gem_constant_name}::RailsLensExtension"
|
144
|
+
|
145
|
+
# Skip gems that are likely to cause autoload issues
|
146
|
+
next if %w[digest openssl uri net json].include?(gem_name)
|
147
|
+
|
148
|
+
# First check if the gem constant exists without triggering autoload
|
149
|
+
next unless Object.const_defined?(gem_constant_name, false)
|
150
|
+
|
151
|
+
gem_constant = Object.const_get(gem_constant_name)
|
152
|
+
next unless gem_constant.is_a?(Module)
|
153
|
+
|
154
|
+
# Then check if it has a RailsLensExtension without triggering autoload
|
155
|
+
next unless gem_constant.const_defined?('RailsLensExtension', false)
|
156
|
+
|
157
|
+
extension_class = gem_constant.const_get('RailsLensExtension')
|
158
|
+
if valid_extension?(extension_class)
|
159
|
+
extensions << extension_class
|
160
|
+
else
|
161
|
+
log_extension_error("Gem extension #{extension_constant_name} failed validation", gem_name)
|
162
|
+
end
|
163
|
+
rescue NameError
|
164
|
+
# No extension found in this gem - this is normal, not an error
|
165
|
+
rescue StandardError => e
|
166
|
+
log_extension_error("Error loading gem extension: #{e.message}", gem_name)
|
167
|
+
end
|
168
|
+
|
169
|
+
extensions
|
170
|
+
end
|
171
|
+
|
172
|
+
def load_custom_extensions
|
173
|
+
extensions = []
|
174
|
+
|
175
|
+
custom_paths = RailsLens.config.extensions[:custom_paths]
|
176
|
+
custom_paths.each do |path|
|
177
|
+
next unless File.directory?(path)
|
178
|
+
|
179
|
+
Dir[File.join(path, '*.rb')].each do |file|
|
180
|
+
require file
|
181
|
+
|
182
|
+
# Try to determine the class name from the file
|
183
|
+
basename = File.basename(file, '.rb')
|
184
|
+
class_name = basename.split('_').map(&:capitalize).join
|
185
|
+
|
186
|
+
# Check in various namespaces
|
187
|
+
found = false
|
188
|
+
[class_name, "RailsLens::Extensions::#{class_name}"].each do |full_name|
|
189
|
+
extension_class = Object.const_get(full_name)
|
190
|
+
if valid_extension?(extension_class)
|
191
|
+
extensions << extension_class
|
192
|
+
found = true
|
193
|
+
break
|
194
|
+
else
|
195
|
+
log_extension_error("Custom extension #{full_name} failed validation", file)
|
196
|
+
end
|
197
|
+
rescue NameError
|
198
|
+
# Try next namespace
|
199
|
+
end
|
200
|
+
|
201
|
+
unless found
|
202
|
+
log_extension_error(
|
203
|
+
"No valid extension class found (tried: #{class_name}, RailsLens::Extensions::#{class_name})", file
|
204
|
+
)
|
205
|
+
end
|
206
|
+
rescue LoadError, SyntaxError => e
|
207
|
+
log_extension_error("Failed to load custom extension file: #{e.message}", file)
|
208
|
+
rescue StandardError => e
|
209
|
+
log_extension_error("Unexpected error loading custom extension: #{e.message}", file)
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
extensions
|
214
|
+
end
|
215
|
+
|
216
|
+
def valid_extension?(klass)
|
217
|
+
klass.is_a?(Class) &&
|
218
|
+
klass.respond_to?(:gem_name) &&
|
219
|
+
klass.respond_to?(:detect?) &&
|
220
|
+
klass.respond_to?(:interface_version) &&
|
221
|
+
klass.respond_to?(:compatible?) &&
|
222
|
+
klass.compatible?
|
223
|
+
end
|
224
|
+
|
225
|
+
def log_extension_error(message, context = nil)
|
226
|
+
error_reporting = RailsLens.config.extensions[:error_reporting] || :warn
|
227
|
+
|
228
|
+
case error_reporting
|
229
|
+
when :silent
|
230
|
+
# Do nothing
|
231
|
+
when :warn
|
232
|
+
if Rails.logger
|
233
|
+
Rails.logger.warn "[RailsLens Extensions] #{message}#{" (#{context})" if context}"
|
234
|
+
else
|
235
|
+
Rails.logger.debug { "Warning: [RailsLens Extensions] #{message}#{" (#{context})" if context}" }
|
236
|
+
end
|
237
|
+
when :verbose
|
238
|
+
if Rails.logger
|
239
|
+
Rails.logger.error "[RailsLens Extensions] #{message}#{" (#{context})" if context}"
|
240
|
+
else
|
241
|
+
Rails.logger.debug { "Error: [RailsLens Extensions] #{message}#{" (#{context})" if context}" }
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
def log_extension_method_error(extension_class, method_name, error, model_class)
|
247
|
+
context = "#{extension_class.name}##{method_name} for #{model_class.name}"
|
248
|
+
error_reporting = RailsLens.config.extensions[:error_reporting] || :warn
|
249
|
+
|
250
|
+
message = case error_reporting
|
251
|
+
when :verbose
|
252
|
+
"#{error.message}\n#{error.backtrace&.first(5)&.join("\n")}"
|
253
|
+
else
|
254
|
+
error.message
|
255
|
+
end
|
256
|
+
|
257
|
+
log_extension_error("Method failed: #{message}", context)
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|
261
|
+
end
|
@@ -0,0 +1,194 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
module Extensions
|
5
|
+
class Base
|
6
|
+
INTERFACE_VERSION = '1.0'
|
7
|
+
|
8
|
+
class << self
|
9
|
+
def gem_name
|
10
|
+
raise NotImplementedError, "#{self.class.name} must implement .gem_name"
|
11
|
+
end
|
12
|
+
|
13
|
+
def detect?
|
14
|
+
raise NotImplementedError, "#{self.class.name} must implement .detect?"
|
15
|
+
end
|
16
|
+
|
17
|
+
def interface_version
|
18
|
+
self::INTERFACE_VERSION
|
19
|
+
rescue NameError
|
20
|
+
INTERFACE_VERSION
|
21
|
+
end
|
22
|
+
|
23
|
+
def compatible?
|
24
|
+
required_version = RailsLens.config.extensions[:interface_version]
|
25
|
+
Gem::Version.new(interface_version) >= Gem::Version.new(required_version)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Helper method for gem-based detection
|
29
|
+
def gem_available?(gem_name)
|
30
|
+
Gem::Specification.find_by_name(gem_name)
|
31
|
+
true
|
32
|
+
rescue Gem::LoadError
|
33
|
+
false
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
attr_reader :model_class
|
38
|
+
|
39
|
+
def initialize(model_class)
|
40
|
+
@model_class = model_class
|
41
|
+
end
|
42
|
+
|
43
|
+
# Override this method to provide model-specific annotations
|
44
|
+
def annotate
|
45
|
+
nil
|
46
|
+
end
|
47
|
+
|
48
|
+
# Override this method to provide analysis notes
|
49
|
+
def notes
|
50
|
+
[]
|
51
|
+
end
|
52
|
+
|
53
|
+
# Override this method to provide ERD additions
|
54
|
+
def erd_additions
|
55
|
+
{
|
56
|
+
relationships: [],
|
57
|
+
badges: [],
|
58
|
+
attributes: {}
|
59
|
+
}
|
60
|
+
end
|
61
|
+
|
62
|
+
# Validation helper methods
|
63
|
+
def safe_method_call(method_name, default_value = nil)
|
64
|
+
return default_value unless respond_to?(method_name)
|
65
|
+
|
66
|
+
send(method_name)
|
67
|
+
rescue StandardError => e
|
68
|
+
log_method_error(method_name, e)
|
69
|
+
default_value
|
70
|
+
end
|
71
|
+
|
72
|
+
def validate_array_result(result, method_name = 'unknown')
|
73
|
+
return [] unless result.is_a?(Array)
|
74
|
+
|
75
|
+
result.select { |item| item.is_a?(String) }
|
76
|
+
rescue StandardError => e
|
77
|
+
log_method_error(method_name, e)
|
78
|
+
[]
|
79
|
+
end
|
80
|
+
|
81
|
+
def validate_hash_result(result, required_keys = [], method_name = 'unknown')
|
82
|
+
unless result.is_a?(Hash)
|
83
|
+
log_method_error(method_name, "Expected Hash, got #{result.class}")
|
84
|
+
return required_keys.index_with { |_key| [] }
|
85
|
+
end
|
86
|
+
|
87
|
+
# Ensure required keys exist with default values
|
88
|
+
required_keys.each do |key|
|
89
|
+
result[key] ||= key == :attributes ? {} : []
|
90
|
+
end
|
91
|
+
|
92
|
+
result
|
93
|
+
rescue StandardError => e
|
94
|
+
log_method_error(method_name, e)
|
95
|
+
required_keys.index_with { |key| key == :attributes ? {} : [] }
|
96
|
+
end
|
97
|
+
|
98
|
+
protected
|
99
|
+
|
100
|
+
# Helper methods for extensions
|
101
|
+
|
102
|
+
def table_name
|
103
|
+
model_class.table_name
|
104
|
+
end
|
105
|
+
|
106
|
+
def connection
|
107
|
+
model_class.connection
|
108
|
+
end
|
109
|
+
|
110
|
+
def columns
|
111
|
+
@columns ||= connection.columns(table_name)
|
112
|
+
end
|
113
|
+
|
114
|
+
def column_names
|
115
|
+
@column_names ||= columns.map(&:name)
|
116
|
+
end
|
117
|
+
|
118
|
+
def has_column?(column_name)
|
119
|
+
column_names.include?(column_name.to_s)
|
120
|
+
end
|
121
|
+
|
122
|
+
def indexes
|
123
|
+
@indexes ||= connection.indexes(table_name)
|
124
|
+
end
|
125
|
+
|
126
|
+
def index_names
|
127
|
+
@index_names ||= indexes.map(&:name)
|
128
|
+
end
|
129
|
+
|
130
|
+
def has_index?(column_name)
|
131
|
+
indexes.any? { |index| index.columns.include?(column_name.to_s) }
|
132
|
+
end
|
133
|
+
|
134
|
+
def foreign_keys
|
135
|
+
@foreign_keys ||= if connection.respond_to?(:foreign_keys)
|
136
|
+
connection.foreign_keys(table_name)
|
137
|
+
else
|
138
|
+
[]
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def associations
|
143
|
+
@associations ||= model_class.reflect_on_all_associations
|
144
|
+
end
|
145
|
+
|
146
|
+
def has_many_associations
|
147
|
+
associations.select { |a| a.macro == :has_many }
|
148
|
+
end
|
149
|
+
|
150
|
+
def belongs_to_associations
|
151
|
+
associations.select { |a| a.macro == :belongs_to }
|
152
|
+
end
|
153
|
+
|
154
|
+
def has_one_associations
|
155
|
+
associations.select { |a| a.macro == :has_one }
|
156
|
+
end
|
157
|
+
|
158
|
+
def has_and_belongs_to_many_associations
|
159
|
+
associations.select { |a| a.macro == :has_and_belongs_to_many }
|
160
|
+
end
|
161
|
+
|
162
|
+
private
|
163
|
+
|
164
|
+
def log_method_error(method_name, error)
|
165
|
+
error_reporting = RailsLens.config.extensions[:error_reporting] || :warn
|
166
|
+
context = "#{self.class.name}##{method_name} for #{model_class.name}"
|
167
|
+
|
168
|
+
message = case error_reporting
|
169
|
+
when :verbose
|
170
|
+
error.is_a?(String) ? error : "#{error.message}\n#{error.backtrace&.first(3)&.join("\n")}"
|
171
|
+
else
|
172
|
+
error.is_a?(String) ? error : error.message
|
173
|
+
end
|
174
|
+
|
175
|
+
case error_reporting
|
176
|
+
when :silent
|
177
|
+
# Do nothing
|
178
|
+
when :warn
|
179
|
+
if Rails.logger
|
180
|
+
Rails.logger.warn "[RailsLens Extensions] Method failed: #{message} (#{context})"
|
181
|
+
else
|
182
|
+
Rails.logger.debug { "Warning: [RailsLens Extensions] Method failed: #{message} (#{context})" }
|
183
|
+
end
|
184
|
+
when :verbose
|
185
|
+
if Rails.logger
|
186
|
+
Rails.logger.error "[RailsLens Extensions] Method failed: #{message} (#{context})"
|
187
|
+
else
|
188
|
+
Rails.logger.debug { "Error: [RailsLens Extensions] Method failed: #{message} (#{context})" }
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
@@ -0,0 +1,157 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
module Extensions
|
5
|
+
class ClosureTreeExt < Base
|
6
|
+
def self.gem_name
|
7
|
+
'closure_tree'
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.detect?
|
11
|
+
if gem_available?(gem_name)
|
12
|
+
require gem_name
|
13
|
+
true
|
14
|
+
else
|
15
|
+
false
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def annotate
|
20
|
+
return nil unless model_uses_closure_tree?
|
21
|
+
|
22
|
+
lines = []
|
23
|
+
lines << '== Hierarchy (ClosureTree)'
|
24
|
+
lines << "Parent Column: #{parent_column_name}"
|
25
|
+
lines << "Hierarchy Table: #{hierarchy_table_name}"
|
26
|
+
|
27
|
+
lines << "Order Column: #{order_column}" if order_column
|
28
|
+
|
29
|
+
lines << "Depth Column: #{depth_column}" if depth_column && has_column?(depth_column)
|
30
|
+
|
31
|
+
lines.join("\n")
|
32
|
+
end
|
33
|
+
|
34
|
+
def notes
|
35
|
+
return [] unless model_uses_closure_tree?
|
36
|
+
|
37
|
+
notes = []
|
38
|
+
|
39
|
+
# Check parent column index
|
40
|
+
notes << "Missing index on parent column '#{parent_column_name}'" unless has_index?(parent_column_name)
|
41
|
+
|
42
|
+
# Check hierarchy table existence and indexes
|
43
|
+
if hierarchy_table_exists?
|
44
|
+
unless has_hierarchy_indexes?
|
45
|
+
notes << "Hierarchy table '#{hierarchy_table_name}' needs compound index on (ancestor_id, descendant_id)"
|
46
|
+
end
|
47
|
+
|
48
|
+
unless has_hierarchy_depth_index?
|
49
|
+
notes << 'Consider adding index on generations column in hierarchy table for depth queries'
|
50
|
+
end
|
51
|
+
else
|
52
|
+
notes << "Hierarchy table '#{hierarchy_table_name}' does not exist - run migrations"
|
53
|
+
end
|
54
|
+
|
55
|
+
# Check for counter cache
|
56
|
+
if should_have_counter_cache? && !has_counter_cache?
|
57
|
+
notes << "Consider adding counter cache '#{suggested_counter_cache_name}' for children count"
|
58
|
+
end
|
59
|
+
|
60
|
+
# Check depth column
|
61
|
+
if model_class.respond_to?(:cache_depth?) && model_class.cache_depth? && !has_column?(depth_column)
|
62
|
+
notes << "Depth caching enabled but column '#{depth_column}' is missing"
|
63
|
+
end
|
64
|
+
|
65
|
+
notes
|
66
|
+
end
|
67
|
+
|
68
|
+
def erd_additions
|
69
|
+
return default_erd_additions unless model_uses_closure_tree?
|
70
|
+
|
71
|
+
{
|
72
|
+
relationships: [
|
73
|
+
{
|
74
|
+
type: 'hierarchy',
|
75
|
+
from: table_name,
|
76
|
+
to: hierarchy_table_name,
|
77
|
+
label: 'closure table',
|
78
|
+
style: 'dashed'
|
79
|
+
}
|
80
|
+
],
|
81
|
+
badges: ['tree'],
|
82
|
+
attributes: {
|
83
|
+
tree_type: 'closure_tree',
|
84
|
+
hierarchy_table: hierarchy_table_name
|
85
|
+
}
|
86
|
+
}
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def model_uses_closure_tree?
|
92
|
+
# Check for both acts_as_tree and has_closure_tree
|
93
|
+
(model_class.respond_to?(:acts_as_tree) || model_class.respond_to?(:has_closure_tree)) &&
|
94
|
+
model_class.respond_to?(:_ct)
|
95
|
+
end
|
96
|
+
|
97
|
+
def parent_column_name
|
98
|
+
model_class._ct.parent_column_name
|
99
|
+
end
|
100
|
+
|
101
|
+
def hierarchy_table_name
|
102
|
+
model_class._ct.hierarchy_table_name
|
103
|
+
end
|
104
|
+
|
105
|
+
def order_column
|
106
|
+
model_class._ct.order_column
|
107
|
+
end
|
108
|
+
|
109
|
+
def depth_column
|
110
|
+
"#{model_class._ct.name_column}_depth"
|
111
|
+
end
|
112
|
+
|
113
|
+
def hierarchy_table_exists?
|
114
|
+
connection.table_exists?(hierarchy_table_name)
|
115
|
+
end
|
116
|
+
|
117
|
+
def has_hierarchy_indexes?
|
118
|
+
return false unless hierarchy_table_exists?
|
119
|
+
|
120
|
+
indexes = connection.indexes(hierarchy_table_name)
|
121
|
+
indexes.any? do |index|
|
122
|
+
index.columns.sort == %w[ancestor_id descendant_id].sort
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def has_hierarchy_depth_index?
|
127
|
+
return false unless hierarchy_table_exists?
|
128
|
+
|
129
|
+
indexes = connection.indexes(hierarchy_table_name)
|
130
|
+
indexes.any? do |index|
|
131
|
+
index.columns.include?('generations')
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def should_have_counter_cache?
|
136
|
+
# Suggest counter cache for models that likely have many children
|
137
|
+
has_many_associations.any? { |a| a.name.to_s.match?(/child|children|descendant/) }
|
138
|
+
end
|
139
|
+
|
140
|
+
def has_counter_cache?
|
141
|
+
column_names.any? { |col| col.match?(/children_count|descendants_count/) }
|
142
|
+
end
|
143
|
+
|
144
|
+
def suggested_counter_cache_name
|
145
|
+
'children_count'
|
146
|
+
end
|
147
|
+
|
148
|
+
def default_erd_additions
|
149
|
+
{
|
150
|
+
relationships: [],
|
151
|
+
badges: [],
|
152
|
+
attributes: {}
|
153
|
+
}
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|