rails_lens 0.0.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/LICENSE.txt +2 -2
  4. data/README.md +463 -9
  5. data/exe/rails_lens +25 -0
  6. data/lib/rails_lens/analyzers/association_analyzer.rb +111 -0
  7. data/lib/rails_lens/analyzers/base.rb +35 -0
  8. data/lib/rails_lens/analyzers/best_practices_analyzer.rb +114 -0
  9. data/lib/rails_lens/analyzers/column_analyzer.rb +97 -0
  10. data/lib/rails_lens/analyzers/composite_keys.rb +62 -0
  11. data/lib/rails_lens/analyzers/database_constraints.rb +35 -0
  12. data/lib/rails_lens/analyzers/delegated_types.rb +129 -0
  13. data/lib/rails_lens/analyzers/enums.rb +34 -0
  14. data/lib/rails_lens/analyzers/error_handling.rb +66 -0
  15. data/lib/rails_lens/analyzers/foreign_key_analyzer.rb +47 -0
  16. data/lib/rails_lens/analyzers/generated_columns.rb +56 -0
  17. data/lib/rails_lens/analyzers/index_analyzer.rb +128 -0
  18. data/lib/rails_lens/analyzers/inheritance.rb +212 -0
  19. data/lib/rails_lens/analyzers/notes.rb +325 -0
  20. data/lib/rails_lens/analyzers/performance_analyzer.rb +110 -0
  21. data/lib/rails_lens/annotation_pipeline.rb +87 -0
  22. data/lib/rails_lens/cli.rb +176 -0
  23. data/lib/rails_lens/cli_error_handler.rb +86 -0
  24. data/lib/rails_lens/commands.rb +164 -0
  25. data/lib/rails_lens/connection.rb +133 -0
  26. data/lib/rails_lens/erd/column_type_formatter.rb +32 -0
  27. data/lib/rails_lens/erd/domain_color_mapper.rb +40 -0
  28. data/lib/rails_lens/erd/mysql_column_type_formatter.rb +19 -0
  29. data/lib/rails_lens/erd/postgresql_column_type_formatter.rb +19 -0
  30. data/lib/rails_lens/erd/visualizer.rb +329 -0
  31. data/lib/rails_lens/errors.rb +78 -0
  32. data/lib/rails_lens/extension_loader.rb +261 -0
  33. data/lib/rails_lens/extensions/base.rb +194 -0
  34. data/lib/rails_lens/extensions/closure_tree_ext.rb +157 -0
  35. data/lib/rails_lens/file_insertion_helper.rb +168 -0
  36. data/lib/rails_lens/mailer/annotator.rb +226 -0
  37. data/lib/rails_lens/mailer/extractor.rb +201 -0
  38. data/lib/rails_lens/model_detector.rb +252 -0
  39. data/lib/rails_lens/parsers/class_info.rb +46 -0
  40. data/lib/rails_lens/parsers/module_info.rb +33 -0
  41. data/lib/rails_lens/parsers/parser_result.rb +55 -0
  42. data/lib/rails_lens/parsers/prism_parser.rb +90 -0
  43. data/lib/rails_lens/parsers.rb +10 -0
  44. data/lib/rails_lens/providers/association_notes_provider.rb +11 -0
  45. data/lib/rails_lens/providers/base.rb +37 -0
  46. data/lib/rails_lens/providers/best_practices_notes_provider.rb +11 -0
  47. data/lib/rails_lens/providers/column_notes_provider.rb +11 -0
  48. data/lib/rails_lens/providers/composite_keys_provider.rb +11 -0
  49. data/lib/rails_lens/providers/database_constraints_provider.rb +11 -0
  50. data/lib/rails_lens/providers/delegated_types_provider.rb +11 -0
  51. data/lib/rails_lens/providers/enums_provider.rb +11 -0
  52. data/lib/rails_lens/providers/extension_notes_provider.rb +20 -0
  53. data/lib/rails_lens/providers/extensions_provider.rb +22 -0
  54. data/lib/rails_lens/providers/foreign_key_notes_provider.rb +11 -0
  55. data/lib/rails_lens/providers/generated_columns_provider.rb +11 -0
  56. data/lib/rails_lens/providers/index_notes_provider.rb +20 -0
  57. data/lib/rails_lens/providers/inheritance_provider.rb +23 -0
  58. data/lib/rails_lens/providers/notes_provider_base.rb +25 -0
  59. data/lib/rails_lens/providers/performance_notes_provider.rb +11 -0
  60. data/lib/rails_lens/providers/schema_provider.rb +61 -0
  61. data/lib/rails_lens/providers/section_provider_base.rb +28 -0
  62. data/lib/rails_lens/railtie.rb +17 -0
  63. data/lib/rails_lens/rake_bootstrapper.rb +18 -0
  64. data/lib/rails_lens/route/annotator.rb +268 -0
  65. data/lib/rails_lens/route/extractor.rb +133 -0
  66. data/lib/rails_lens/route/parser.rb +59 -0
  67. data/lib/rails_lens/schema/adapters/base.rb +345 -0
  68. data/lib/rails_lens/schema/adapters/database_info.rb +118 -0
  69. data/lib/rails_lens/schema/adapters/mysql.rb +279 -0
  70. data/lib/rails_lens/schema/adapters/postgresql.rb +197 -0
  71. data/lib/rails_lens/schema/adapters/sqlite3.rb +96 -0
  72. data/lib/rails_lens/schema/annotation.rb +144 -0
  73. data/lib/rails_lens/schema/annotation_manager.rb +202 -0
  74. data/lib/rails_lens/tasks/annotate.rake +35 -0
  75. data/lib/rails_lens/tasks/erd.rake +24 -0
  76. data/lib/rails_lens/tasks/mailers.rake +27 -0
  77. data/lib/rails_lens/tasks/routes.rake +27 -0
  78. data/lib/rails_lens/tasks/schema.rake +108 -0
  79. data/lib/rails_lens/version.rb +5 -0
  80. data/lib/rails_lens.rb +138 -5
  81. metadata +215 -11
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'error_handling'
4
+
5
+ module RailsLens
6
+ module Analyzers
7
+ class Base
8
+ include ErrorHandling
9
+
10
+ attr_reader :model_class
11
+
12
+ def initialize(model_class)
13
+ @model_class = model_class
14
+ end
15
+
16
+ def analyze
17
+ raise NotImplementedError, 'Subclasses must implement #analyze'
18
+ end
19
+
20
+ protected
21
+
22
+ def table_name
23
+ @table_name ||= model_class.table_name
24
+ end
25
+
26
+ def connection
27
+ @connection ||= model_class.connection
28
+ end
29
+
30
+ def adapter_name
31
+ @adapter_name ||= connection.adapter_name
32
+ end
33
+ end
34
+ end
35
+ end
@@ -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,129 @@
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
+ types_list = if delegated_type_info[:types].respond_to?(:keys)
21
+ delegated_type_info[:types].keys
22
+ else
23
+ Array(delegated_type_info[:types])
24
+ end
25
+ lines << "Types: #{types_list.join(', ')}"
26
+
27
+ lines.join("\n")
28
+ end
29
+
30
+ private
31
+
32
+ def delegated_type_model?
33
+ # Check if model uses delegated_type by looking for the Rails-provided class methods
34
+ # Rails delegated_type creates a "prefix_types" class method
35
+
36
+ # Skip abstract models that don't have tables configured
37
+ return false unless model_class.respond_to?(:table_exists?) && model_class.table_exists?
38
+
39
+ columns = model_class.column_names
40
+
41
+ # Look for columns ending with _type that have corresponding _id columns
42
+ # and check if the model has the corresponding delegated type class method
43
+ columns.any? do |col|
44
+ if col.end_with?('_type')
45
+ prefix = col.sub(/_type$/, '')
46
+ if columns.include?("#{prefix}_id")
47
+ # Check if Rails delegated_type created the "prefix_types" class method
48
+ model_class.respond_to?("#{prefix}_types")
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ def find_delegated_type_info
55
+ # Find columns that match the delegated type pattern and have the Rails class method
56
+ # Skip abstract models that don't have tables configured
57
+ return nil unless model_class.respond_to?(:table_exists?) && model_class.table_exists?
58
+
59
+ columns = model_class.column_names
60
+
61
+ # Find the delegated type by checking for the Rails-provided class method
62
+ delegated_info = nil
63
+ columns.each do |col|
64
+ next unless col.end_with?('_type')
65
+
66
+ prefix = col.sub(/_type$/, '')
67
+ id_column = "#{prefix}_id"
68
+
69
+ next unless columns.include?(id_column) && model_class.respond_to?("#{prefix}_types")
70
+
71
+ # Use the Rails-provided method to get the types
72
+ types = model_class.send("#{prefix}_types")
73
+
74
+ delegated_info = {
75
+ type_column: col,
76
+ id_column: id_column,
77
+ types: types
78
+ }
79
+ break
80
+ end
81
+
82
+ delegated_info
83
+ rescue NoMethodError => e
84
+ Rails.logger.debug { "Failed to find delegated type info for #{model_class.name}: #{e.message}" }
85
+ nil
86
+ rescue ActiveRecord::StatementInvalid => e
87
+ Rails.logger.debug { "Database error finding delegated type info: #{e.message}" }
88
+ nil
89
+ end
90
+
91
+ def polymorphic_association?(prefix)
92
+ # Check if this is a polymorphic association by looking at the model's reflections
93
+ model_class.reflections.values.any? do |reflection|
94
+ reflection.polymorphic? && reflection.name.to_s == prefix
95
+ end
96
+ end
97
+
98
+ def infer_delegated_types(prefix)
99
+ # First try to read from the model file to find delegated_type declaration
100
+ model_file = "app/models/#{model_class.name.underscore}.rb"
101
+ if File.exist?(model_file)
102
+ content = File.read(model_file)
103
+ if (match = content.match(/delegated_type\s+:#{prefix}.*types:\s*%(w|W)\[([^\]]+)\]/))
104
+ types_string = match[2]
105
+ return types_string.scan(/\w+/)
106
+ end
107
+ end
108
+
109
+ # Fallback to database
110
+ if model_class.table_exists?
111
+ model_class
112
+ .where.not("#{prefix}_type" => nil)
113
+ .distinct
114
+ .pluck("#{prefix}_type")
115
+ .compact
116
+ .sort
117
+ else
118
+ []
119
+ end
120
+ rescue ActiveRecord::StatementInvalid => e
121
+ Rails.logger.debug { "Database error inferring delegated types: #{e.message}" }
122
+ []
123
+ rescue Errno::ENOENT => e
124
+ Rails.logger.debug { "File not found when inferring delegated types: #{e.message}" }
125
+ []
126
+ end
127
+ end
128
+ end
129
+ 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