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,114 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
module Analyzers
|
5
|
+
class BestPracticesAnalyzer < Base
|
6
|
+
def analyze
|
7
|
+
notes = []
|
8
|
+
notes.concat(analyze_timestamps)
|
9
|
+
notes.concat(analyze_soft_deletes)
|
10
|
+
notes.concat(analyze_sti_columns)
|
11
|
+
notes.concat(analyze_naming_conventions)
|
12
|
+
notes
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def analyze_timestamps
|
18
|
+
notes = []
|
19
|
+
|
20
|
+
notes << 'Missing timestamp columns (created_at, updated_at)' unless has_timestamps?
|
21
|
+
|
22
|
+
if has_column?('created_at') && !has_column?('updated_at')
|
23
|
+
notes << 'Has created_at but missing updated_at'
|
24
|
+
elsif !has_column?('created_at') && has_column?('updated_at')
|
25
|
+
notes << 'Has updated_at but missing created_at'
|
26
|
+
end
|
27
|
+
|
28
|
+
notes
|
29
|
+
end
|
30
|
+
|
31
|
+
def analyze_soft_deletes
|
32
|
+
notes = []
|
33
|
+
|
34
|
+
soft_delete_columns.each do |column|
|
35
|
+
notes << "Soft delete column '#{column.name}' should be indexed" unless indexed?(column)
|
36
|
+
end
|
37
|
+
|
38
|
+
notes
|
39
|
+
end
|
40
|
+
|
41
|
+
def analyze_sti_columns
|
42
|
+
notes = []
|
43
|
+
|
44
|
+
if sti_model? && type_column
|
45
|
+
notes << "STI type column '#{type_column.name}' should be indexed" unless indexed?(type_column)
|
46
|
+
|
47
|
+
notes << "STI type column '#{type_column.name}' should have NOT NULL constraint" if type_column.null
|
48
|
+
end
|
49
|
+
|
50
|
+
notes
|
51
|
+
end
|
52
|
+
|
53
|
+
def analyze_naming_conventions
|
54
|
+
notes = []
|
55
|
+
|
56
|
+
# Check for non-conventional column names
|
57
|
+
columns.each do |column|
|
58
|
+
if column.name.match?(/^(is|has)_/i)
|
59
|
+
notes << "Column '#{column.name}' uses non-conventional prefix - consider removing 'is_' or 'has_'"
|
60
|
+
end
|
61
|
+
|
62
|
+
if column.name.match?(/Id$/) # Capital I
|
63
|
+
notes << "Column '#{column.name}' should use snake_case (e.g., '#{column.name.underscore}')"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Check table naming
|
68
|
+
if !table_name.match?(/^[a-z_]+$/) || table_name != table_name.pluralize
|
69
|
+
notes << "Table name '#{table_name}' doesn't follow Rails conventions (should be plural, snake_case)"
|
70
|
+
end
|
71
|
+
|
72
|
+
notes
|
73
|
+
end
|
74
|
+
|
75
|
+
def has_timestamps?
|
76
|
+
has_column?('created_at') && has_column?('updated_at')
|
77
|
+
end
|
78
|
+
|
79
|
+
def soft_delete_columns
|
80
|
+
columns.select { |c| c.name.match?(/deleted_at|archived_at|discarded_at/i) }
|
81
|
+
end
|
82
|
+
|
83
|
+
def sti_model?
|
84
|
+
model_class.base_class != model_class || has_column?('type')
|
85
|
+
end
|
86
|
+
|
87
|
+
def type_column
|
88
|
+
columns.find { |c| c.name == 'type' }
|
89
|
+
end
|
90
|
+
|
91
|
+
def has_column?(name)
|
92
|
+
model_class.column_names.include?(name)
|
93
|
+
end
|
94
|
+
|
95
|
+
def indexed?(column)
|
96
|
+
connection.indexes(table_name).any? do |index|
|
97
|
+
index.columns.include?(column.name)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def columns
|
102
|
+
@columns ||= model_class.columns
|
103
|
+
end
|
104
|
+
|
105
|
+
def connection
|
106
|
+
model_class.connection
|
107
|
+
end
|
108
|
+
|
109
|
+
def table_name
|
110
|
+
model_class.table_name
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
module Analyzers
|
5
|
+
class ColumnAnalyzer < Base
|
6
|
+
def analyze
|
7
|
+
notes = []
|
8
|
+
notes.concat(analyze_null_constraints)
|
9
|
+
notes.concat(analyze_default_values)
|
10
|
+
notes.concat(analyze_column_types)
|
11
|
+
notes
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def analyze_null_constraints
|
17
|
+
columns_needing_not_null.map do |column|
|
18
|
+
"Column '#{column.name}' should probably have NOT NULL constraint"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def analyze_default_values
|
23
|
+
notes = []
|
24
|
+
|
25
|
+
columns.each do |column|
|
26
|
+
if column.type == :boolean && column.default.nil? && column.null
|
27
|
+
notes << "Boolean column '#{column.name}' should have a default value"
|
28
|
+
end
|
29
|
+
|
30
|
+
if status_column?(column) && column.default.nil?
|
31
|
+
notes << "Status column '#{column.name}' should have a default value"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
notes
|
36
|
+
end
|
37
|
+
|
38
|
+
def analyze_column_types
|
39
|
+
notes = []
|
40
|
+
|
41
|
+
columns.each do |column|
|
42
|
+
# Check for float columns used for money
|
43
|
+
if money_column?(column) && column.type == :float
|
44
|
+
notes << "Column '#{column.name}' appears to store monetary values - use decimal instead of float"
|
45
|
+
end
|
46
|
+
|
47
|
+
# Check for string columns that should be integers
|
48
|
+
if counter_column?(column) && column.type != :integer
|
49
|
+
notes << "Counter column '#{column.name}' should be integer type, not #{column.type}"
|
50
|
+
end
|
51
|
+
|
52
|
+
# Check for inappropriately large string columns
|
53
|
+
if column.type == :string && column.limit.nil?
|
54
|
+
notes << "String column '#{column.name}' has no length limit - consider adding one"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
notes
|
59
|
+
end
|
60
|
+
|
61
|
+
def columns_needing_not_null
|
62
|
+
columns.select do |column|
|
63
|
+
column.null &&
|
64
|
+
!column.name.end_with?('_id') && # Foreign keys might be nullable
|
65
|
+
!optional_column?(column) &&
|
66
|
+
!timestamp_column?(column)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def money_column?(column)
|
71
|
+
column.name.match?(/price|cost|amount|fee|rate|salary|budget|revenue|profit|balance/i)
|
72
|
+
end
|
73
|
+
|
74
|
+
def counter_column?(column)
|
75
|
+
column.name.end_with?('_count', '_counter', '_total')
|
76
|
+
end
|
77
|
+
|
78
|
+
def status_column?(column)
|
79
|
+
column.name.match?(/status|state/i) || column.name == 'workflow_state'
|
80
|
+
end
|
81
|
+
|
82
|
+
def optional_column?(column)
|
83
|
+
column.name.match?(/optional|nullable|maybe|perhaps/i) ||
|
84
|
+
column.name.end_with?('_at', '_on', '_date') ||
|
85
|
+
column.name.start_with?('last_', 'next_', 'previous_')
|
86
|
+
end
|
87
|
+
|
88
|
+
def timestamp_column?(column)
|
89
|
+
%w[created_at updated_at deleted_at].include?(column.name)
|
90
|
+
end
|
91
|
+
|
92
|
+
def columns
|
93
|
+
@columns ||= model_class.columns
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../errors'
|
4
|
+
require_relative 'error_handling'
|
5
|
+
|
6
|
+
module RailsLens
|
7
|
+
module Analyzers
|
8
|
+
class CompositeKeys < Base
|
9
|
+
def analyze
|
10
|
+
# First try Rails native support
|
11
|
+
if model_class.respond_to?(:primary_keys) && model_class.primary_keys.is_a?(Array)
|
12
|
+
keys = model_class.primary_keys
|
13
|
+
return format_composite_keys(keys) if keys.length > 1
|
14
|
+
end
|
15
|
+
|
16
|
+
# For PostgreSQL, check the actual database constraints
|
17
|
+
if adapter_name == 'PostgreSQL'
|
18
|
+
keys = detect_composite_primary_key_from_db
|
19
|
+
return format_composite_keys(keys) if keys && keys.length > 1
|
20
|
+
end
|
21
|
+
|
22
|
+
nil
|
23
|
+
rescue NoMethodError => e
|
24
|
+
Rails.logger.debug { "Failed to analyze composite keys for #{model_class.name}: #{e.message}" }
|
25
|
+
nil
|
26
|
+
rescue ActiveRecord::ConnectionNotEstablished => e
|
27
|
+
Rails.logger.debug { "No database connection for #{model_class.name}: #{e.message}" }
|
28
|
+
nil
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def format_composite_keys(keys)
|
34
|
+
lines = ['== Composite Primary Key']
|
35
|
+
lines << "Primary Keys: #{keys.join(', ')}"
|
36
|
+
lines.join("\n")
|
37
|
+
end
|
38
|
+
|
39
|
+
def detect_composite_primary_key_from_db
|
40
|
+
# Query PostgreSQL system catalogs to find composite primary keys
|
41
|
+
sql = <<-SQL.squish
|
42
|
+
SELECT a.attname
|
43
|
+
FROM pg_index i
|
44
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
45
|
+
WHERE i.indrelid = '#{table_name}'::regclass
|
46
|
+
AND i.indisprimary
|
47
|
+
ORDER BY array_position(i.indkey, a.attnum)
|
48
|
+
SQL
|
49
|
+
|
50
|
+
result = connection.execute(sql)
|
51
|
+
keys = result.pluck('attname')
|
52
|
+
keys.empty? ? nil : keys
|
53
|
+
rescue ActiveRecord::StatementInvalid => e
|
54
|
+
Rails.logger.debug { "Failed to detect composite keys from database for #{table_name}: #{e.message}" }
|
55
|
+
nil
|
56
|
+
rescue PG::Error => e
|
57
|
+
Rails.logger.debug { "PostgreSQL error detecting composite keys: #{e.message}" }
|
58
|
+
nil
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../errors'
|
4
|
+
require_relative 'error_handling'
|
5
|
+
|
6
|
+
module RailsLens
|
7
|
+
module Analyzers
|
8
|
+
class DatabaseConstraints < Base
|
9
|
+
def analyze
|
10
|
+
return nil unless connection.respond_to?(:check_constraints)
|
11
|
+
|
12
|
+
constraints = []
|
13
|
+
|
14
|
+
# Get check constraints
|
15
|
+
check_constraints = connection.check_constraints(table_name)
|
16
|
+
return nil if check_constraints.empty?
|
17
|
+
|
18
|
+
constraints << '== Check Constraints'
|
19
|
+
check_constraints.each do |constraint|
|
20
|
+
name = constraint.options[:name] || constraint.name
|
21
|
+
expression = constraint.expression || constraint.options[:validate]
|
22
|
+
constraints << "- #{name}: #{expression}"
|
23
|
+
end
|
24
|
+
|
25
|
+
constraints.empty? ? nil : constraints.join("\n")
|
26
|
+
rescue ActiveRecord::StatementInvalid => e
|
27
|
+
Rails.logger.debug { "Failed to fetch check constraints for #{table_name}: #{e.message}" }
|
28
|
+
nil
|
29
|
+
rescue NoMethodError => e
|
30
|
+
Rails.logger.debug { "Check constraints not supported by adapter: #{e.message}" }
|
31
|
+
nil
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../errors'
|
4
|
+
require_relative 'error_handling'
|
5
|
+
|
6
|
+
module RailsLens
|
7
|
+
module Analyzers
|
8
|
+
class DelegatedTypes < Base
|
9
|
+
def analyze
|
10
|
+
return nil unless delegated_type_model?
|
11
|
+
|
12
|
+
lines = ['== Delegated Type']
|
13
|
+
|
14
|
+
# Find delegated type configuration
|
15
|
+
delegated_type_info = find_delegated_type_info
|
16
|
+
return nil unless delegated_type_info
|
17
|
+
|
18
|
+
lines << "Type Column: #{delegated_type_info[:type_column]}"
|
19
|
+
lines << "ID Column: #{delegated_type_info[:id_column]}"
|
20
|
+
lines << "Types: #{delegated_type_info[:types].join(', ')}"
|
21
|
+
|
22
|
+
lines.join("\n")
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def delegated_type_model?
|
28
|
+
# Check if model uses delegated_type by looking for the Rails-provided class methods
|
29
|
+
# Rails delegated_type creates a "prefix_types" class method
|
30
|
+
|
31
|
+
# Skip abstract models that don't have tables configured
|
32
|
+
return false unless model_class.respond_to?(:table_exists?) && model_class.table_exists?
|
33
|
+
|
34
|
+
columns = model_class.column_names
|
35
|
+
|
36
|
+
# Look for columns ending with _type that have corresponding _id columns
|
37
|
+
# and check if the model has the corresponding delegated type class method
|
38
|
+
columns.any? do |col|
|
39
|
+
if col.end_with?('_type')
|
40
|
+
prefix = col.sub(/_type$/, '')
|
41
|
+
if columns.include?("#{prefix}_id")
|
42
|
+
# Check if Rails delegated_type created the "prefix_types" class method
|
43
|
+
model_class.respond_to?("#{prefix}_types")
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def find_delegated_type_info
|
50
|
+
# Find columns that match the delegated type pattern and have the Rails class method
|
51
|
+
# Skip abstract models that don't have tables configured
|
52
|
+
return nil unless model_class.respond_to?(:table_exists?) && model_class.table_exists?
|
53
|
+
|
54
|
+
columns = model_class.column_names
|
55
|
+
|
56
|
+
# Find the delegated type by checking for the Rails-provided class method
|
57
|
+
delegated_info = nil
|
58
|
+
columns.each do |col|
|
59
|
+
next unless col.end_with?('_type')
|
60
|
+
|
61
|
+
prefix = col.sub(/_type$/, '')
|
62
|
+
id_column = "#{prefix}_id"
|
63
|
+
|
64
|
+
next unless columns.include?(id_column) && model_class.respond_to?("#{prefix}_types")
|
65
|
+
|
66
|
+
# Use the Rails-provided method to get the types
|
67
|
+
types = model_class.send("#{prefix}_types")
|
68
|
+
|
69
|
+
delegated_info = {
|
70
|
+
type_column: col,
|
71
|
+
id_column: id_column,
|
72
|
+
types: types
|
73
|
+
}
|
74
|
+
break
|
75
|
+
end
|
76
|
+
|
77
|
+
delegated_info
|
78
|
+
rescue NoMethodError => e
|
79
|
+
Rails.logger.debug { "Failed to find delegated type info for #{model_class.name}: #{e.message}" }
|
80
|
+
nil
|
81
|
+
rescue ActiveRecord::StatementInvalid => e
|
82
|
+
Rails.logger.debug { "Database error finding delegated type info: #{e.message}" }
|
83
|
+
nil
|
84
|
+
end
|
85
|
+
|
86
|
+
def polymorphic_association?(prefix)
|
87
|
+
# Check if this is a polymorphic association by looking at the model's reflections
|
88
|
+
model_class.reflections.values.any? do |reflection|
|
89
|
+
reflection.polymorphic? && reflection.name.to_s == prefix
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def infer_delegated_types(prefix)
|
94
|
+
# First try to read from the model file to find delegated_type declaration
|
95
|
+
model_file = "app/models/#{model_class.name.underscore}.rb"
|
96
|
+
if File.exist?(model_file)
|
97
|
+
content = File.read(model_file)
|
98
|
+
if (match = content.match(/delegated_type\s+:#{prefix}.*types:\s*%(w|W)\[([^\]]+)\]/))
|
99
|
+
types_string = match[2]
|
100
|
+
return types_string.scan(/\w+/)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# Fallback to database
|
105
|
+
if model_class.table_exists?
|
106
|
+
model_class
|
107
|
+
.where.not("#{prefix}_type" => nil)
|
108
|
+
.distinct
|
109
|
+
.pluck("#{prefix}_type")
|
110
|
+
.compact
|
111
|
+
.sort
|
112
|
+
else
|
113
|
+
[]
|
114
|
+
end
|
115
|
+
rescue ActiveRecord::StatementInvalid => e
|
116
|
+
Rails.logger.debug { "Database error inferring delegated types: #{e.message}" }
|
117
|
+
[]
|
118
|
+
rescue Errno::ENOENT => e
|
119
|
+
Rails.logger.debug { "File not found when inferring delegated types: #{e.message}" }
|
120
|
+
[]
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
module Analyzers
|
5
|
+
class Enums < Base
|
6
|
+
def analyze
|
7
|
+
return nil unless model_class.respond_to?(:defined_enums) && model_class.defined_enums.any?
|
8
|
+
|
9
|
+
lines = []
|
10
|
+
lines << '== Enums'
|
11
|
+
|
12
|
+
model_class.defined_enums.each do |name, values|
|
13
|
+
# Detect if it's using integer or string values
|
14
|
+
formatted_values = if values.values.all? { |v| v.is_a?(Integer) }
|
15
|
+
# Integer-based enum
|
16
|
+
values.map { |k, v| "#{k}: #{v}" }.join(', ')
|
17
|
+
else
|
18
|
+
# String-based enum
|
19
|
+
values.map { |k, v| "#{k}: \"#{v}\"" }.join(', ')
|
20
|
+
end
|
21
|
+
lines << "- #{name}: { #{formatted_values} }"
|
22
|
+
|
23
|
+
# Add column type if we can detect it
|
24
|
+
if model_class.table_exists? && model_class.columns_hash[name.to_s]
|
25
|
+
column = model_class.columns_hash[name.to_s]
|
26
|
+
lines.last << " (#{column.type})"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
lines.join("\n")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
module Analyzers
|
5
|
+
# Mixin for consistent error handling across analyzers
|
6
|
+
module ErrorHandling
|
7
|
+
def safe_analyze
|
8
|
+
analyze
|
9
|
+
rescue ActiveRecord::StatementInvalid => e
|
10
|
+
handle_database_error(e)
|
11
|
+
rescue NameError, NoMethodError => e
|
12
|
+
handle_method_error(e)
|
13
|
+
rescue StandardError => e
|
14
|
+
handle_unexpected_error(e)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def handle_database_error(error)
|
20
|
+
ErrorReporter.report(error, {
|
21
|
+
analyzer: self.class.name,
|
22
|
+
model: model_class.name,
|
23
|
+
table: model_class.table_name
|
24
|
+
})
|
25
|
+
[]
|
26
|
+
end
|
27
|
+
|
28
|
+
def handle_method_error(error)
|
29
|
+
# These are likely bugs in our code, so we should log them prominently
|
30
|
+
ErrorReporter.report(error, {
|
31
|
+
analyzer: self.class.name,
|
32
|
+
model: model_class.name,
|
33
|
+
method: error.name
|
34
|
+
})
|
35
|
+
[]
|
36
|
+
end
|
37
|
+
|
38
|
+
def handle_unexpected_error(error)
|
39
|
+
ErrorReporter.report(error, {
|
40
|
+
analyzer: self.class.name,
|
41
|
+
model: model_class.name,
|
42
|
+
type: 'unexpected'
|
43
|
+
})
|
44
|
+
[]
|
45
|
+
end
|
46
|
+
|
47
|
+
def safe_call(default = nil)
|
48
|
+
yield
|
49
|
+
rescue ActiveRecord::StatementInvalid => e
|
50
|
+
ErrorReporter.report(e, {
|
51
|
+
analyzer: self.class.name,
|
52
|
+
model: model_class.name,
|
53
|
+
operation: 'database_query'
|
54
|
+
})
|
55
|
+
default
|
56
|
+
rescue NoMethodError, NameError => e
|
57
|
+
ErrorReporter.report(e, {
|
58
|
+
analyzer: self.class.name,
|
59
|
+
model: model_class.name,
|
60
|
+
operation: 'method_call'
|
61
|
+
})
|
62
|
+
default
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsLens
|
4
|
+
module Analyzers
|
5
|
+
class ForeignKeyAnalyzer < Base
|
6
|
+
def analyze
|
7
|
+
return [] unless connection.supports_foreign_keys?
|
8
|
+
|
9
|
+
notes = []
|
10
|
+
existing_foreign_keys = connection.foreign_keys(table_name)
|
11
|
+
|
12
|
+
belongs_to_associations.each do |association|
|
13
|
+
next if association.polymorphic? # Can't have FK constraints on polymorphic associations
|
14
|
+
|
15
|
+
foreign_key = association.foreign_key
|
16
|
+
referenced_table = association.klass.table_name
|
17
|
+
|
18
|
+
unless foreign_key_exists?(foreign_key, referenced_table, existing_foreign_keys)
|
19
|
+
notes << "Missing foreign key constraint on '#{foreign_key}' referencing '#{referenced_table}'"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
notes
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def belongs_to_associations
|
29
|
+
model_class.reflect_on_all_associations(:belongs_to)
|
30
|
+
end
|
31
|
+
|
32
|
+
def foreign_key_exists?(column, referenced_table, existing_foreign_keys)
|
33
|
+
existing_foreign_keys.any? do |fk|
|
34
|
+
fk.column == column.to_s && fk.to_table == referenced_table
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def connection
|
39
|
+
model_class.connection
|
40
|
+
end
|
41
|
+
|
42
|
+
def table_name
|
43
|
+
model_class.table_name
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../errors'
|
4
|
+
require_relative 'error_handling'
|
5
|
+
|
6
|
+
module RailsLens
|
7
|
+
module Analyzers
|
8
|
+
class GeneratedColumns < Base
|
9
|
+
def analyze
|
10
|
+
return nil unless adapter_name == 'PostgreSQL'
|
11
|
+
|
12
|
+
generated_columns = detect_generated_columns
|
13
|
+
return nil if generated_columns.empty?
|
14
|
+
|
15
|
+
lines = ['== Generated Columns']
|
16
|
+
generated_columns.each do |column|
|
17
|
+
lines << "- #{column[:name]} (#{column[:expression]})"
|
18
|
+
end
|
19
|
+
|
20
|
+
lines.join("\n")
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def detect_generated_columns
|
26
|
+
# PostgreSQL system query to find generated columns
|
27
|
+
sql = <<-SQL.squish
|
28
|
+
SELECT#{' '}
|
29
|
+
a.attname AS column_name,
|
30
|
+
pg_get_expr(d.adbin, d.adrelid) AS generation_expression
|
31
|
+
FROM pg_attribute a
|
32
|
+
JOIN pg_class c ON a.attrelid = c.oid
|
33
|
+
LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum
|
34
|
+
WHERE c.relname = '#{table_name}'
|
35
|
+
AND a.attgenerated != ''
|
36
|
+
AND NOT a.attisdropped
|
37
|
+
ORDER BY a.attnum
|
38
|
+
SQL
|
39
|
+
|
40
|
+
result = connection.execute(sql)
|
41
|
+
result.map do |row|
|
42
|
+
{
|
43
|
+
name: row['column_name'],
|
44
|
+
expression: row['generation_expression']
|
45
|
+
}
|
46
|
+
end
|
47
|
+
rescue ActiveRecord::StatementInvalid => e
|
48
|
+
Rails.logger.debug { "Failed to detect generated columns for #{table_name}: #{e.message}" }
|
49
|
+
[]
|
50
|
+
rescue PG::Error => e
|
51
|
+
Rails.logger.debug { "PostgreSQL error detecting generated columns: #{e.message}" }
|
52
|
+
[]
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|