Module: RailsForge::DatabaseAnalyzer

Defined in:
lib/railsforge/database_analyzer.rb

Overview

DatabaseAnalyzer module scans models and database schema for issues

Defined Under Namespace

Classes: DatabaseError

Class Method Summary collapse

Class Method Details

.analyze(base_path = nil) ⇒ Array<Hash>

Analyzes database and models for common issues

Parameters:

  • base_path (String) (defaults to: nil)

    Rails app root path

Returns:

  • (Array<Hash>)

    Analysis results

Raises:



22
23
24
25
26
27
28
29
30
31
32
33
34
35
# File 'lib/railsforge/database_analyzer.rb', line 22

def self.analyze(base_path = nil)
  base_path ||= find_rails_app_path
  raise DatabaseError, "Not in a Rails application directory" unless base_path

  results = []

  # Analyze models
  results += analyze_models(base_path)

  # Analyze schema if available
  results += analyze_schema(base_path)

  results
end

.analyze_models(base_path) ⇒ Array<Hash>

Analyzes models for missing indexes and constraints

Parameters:

  • base_path (String)

    Rails app root path

Returns:

  • (Array<Hash>)

    Analysis results



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/railsforge/database_analyzer.rb', line 40

def self.analyze_models(base_path)
  results = []
  models_dir = File.join(base_path, "app", "models")

  return results unless Dir.exist?(models_dir)

  Dir.glob(File.join(models_dir, "**", "*.rb")).each do |file|
    next if file.end_with?("_application.rb")

    model_name = File.basename(file, ".rb")
    content = File.read(file)

    # Skip abstract classes
    next if content.include?("abstract_class = true")

    # Check for missing indexes on foreign keys
    results += check_foreign_keys(content, model_name)

    # Check for uniqueness constraints
    results += check_uniqueness(content, model_name)

    # Check for missing database indexes
    results += check_indexes(content, model_name)
  end

  results
end

.analyze_schema(base_path) ⇒ Array<Hash>

Analyzes schema.rb for missing foreign keys and indexes

Parameters:

  • base_path (String)

    Rails app root path

Returns:

  • (Array<Hash>)

    Analysis results



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/railsforge/database_analyzer.rb', line 71

def self.analyze_schema(base_path)
  results = []
  schema_file = File.join(base_path, "db", "schema.rb")

  return results unless File.exist?(schema_file)

  content = File.read(schema_file)

  # Parse create_table statements
  tables = content.scan(/create_table\s+"(\w+)"/).flatten

  tables.each do |table|
    # Check for tables without indexes
    table_section = content[/create_table\s+"#{table}".*?(?=create_table|\z)/m]

    if table_section
      # Check for missing timestamps indexes
      if table_section.include?("t.datetime")
        results << {
          type: :index,
          table: table,
          issue: "Table #{table} has datetime columns - consider adding indexes",
          suggestion: "add_index :#{table}, :created_at",
          severity: :low
        } unless table_section.include?("index") && table.include?("created_at")
      end
    end
  end

  results
end

.check_foreign_keys(content, model_name) ⇒ Array<Hash>

Checks for missing foreign key indexes

Parameters:

  • content (String)

    Model file content

  • model_name (String)

    Model name

Returns:

  • (Array<Hash>)

    Analysis results



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/railsforge/database_analyzer.rb', line 107

def self.check_foreign_keys(content, model_name)
  results = []

  # Find belongs_to associations
  content.scan(/belongs_to\s+:(\w+)/).each do |assoc|
    assoc_name = assoc[0]

    # Check if foreign key is indexed
    unless content.include?("index") && content.include?("#{assoc_name}_id")
      results << {
        type: :foreign_key,
        model: model_name,
        assoc: assoc_name,
        issue: "Model #{model_name} has belongs_to :#{assoc_name} but may be missing an index",
        suggestion: "add_index :#{tableize(model_name)}, :#{assoc_name}_id",
        severity: :medium
      }
    end
  end

  results
end

.check_indexes(content, model_name) ⇒ Array<Hash>

Checks for commonly indexed fields that may be missing indexes

Parameters:

  • content (String)

    Model file content

  • model_name (String)

    Model name

Returns:

  • (Array<Hash>)

    Analysis results



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/railsforge/database_analyzer.rb', line 161

def self.check_indexes(content, model_name)
  results = []

  # Common fields that should be indexed
  commonly_indexed = %w[email slug token uuid]

  commonly_indexed.each do |field|
    if content.include?(":#{field}") && !content.include?("add_index")
      results << {
        type: :index,
        model: model_name,
        issue: "Model #{model_name} has :#{field} field but may be missing an index",
        suggestion: "add_index :#{tableize(model_name)}, :#{field}",
        severity: :medium
      }
    end
  end

  # Check for has_many :through associations that need indexes
  content.scan(/has_many\s+:(\w+),\s+through:/).each do |assoc|
    results << {
      type: :index,
      model: model_name,
      issue: "Model #{model_name} has has_many :through :#{assoc[0]} - ensure join table has indexes",
      suggestion: "Ensure your join table has composite indexes on both foreign keys",
      severity: :low
    }
  end

  results
end

.check_uniqueness(content, model_name) ⇒ Array<Hash>

Checks for uniqueness validations without database constraints

Parameters:

  • content (String)

    Model file content

  • model_name (String)

    Model name

Returns:

  • (Array<Hash>)

    Analysis results



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/railsforge/database_analyzer.rb', line 134

def self.check_uniqueness(content, model_name)
  results = []

  # Find uniqueness validations
  content.scan(/validates\s+:(\w+),\s+uniqueness:/).each do |field|
    field_name = field[0]

    # Check if there's a unique index
    unless content.include?("unique: true")
      results << {
        type: :uniqueness,
        model: model_name,
        field: field_name,
        issue: "Model #{model_name} validates uniqueness of :#{field_name} but has no unique index",
        suggestion: "add_index :#{tableize(model_name)}, :#{field_name}, unique: true",
        severity: :high
      }
    end
  end

  results
end

.find_rails_app_pathString?

Finds Rails app root path

Returns:

  • (String, nil)

    Rails app path



289
290
291
292
293
294
295
296
297
298
# File 'lib/railsforge/database_analyzer.rb', line 289

def self.find_rails_app_path
  path = Dir.pwd
  10.times do
    return path if File.exist?(File.join(path, "config", "application.rb"))
    parent = File.dirname(path)
    break if parent == path
    path = parent
  end
  nil
end

.generate_migration(suggestion, base_path = nil) ⇒ String

Generates a migration file for a suggested fix

Parameters:

  • suggestion (Hash)

    Suggestion details

  • base_path (String) (defaults to: nil)

    Rails app root

Returns:

  • (String)

    Path to created migration

Raises:



197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/railsforge/database_analyzer.rb', line 197

def self.generate_migration(suggestion, base_path = nil)
  base_path ||= find_rails_app_path
  raise DatabaseError, "Not in a Rails application directory" unless base_path

  migrate_dir = File.join(base_path, "db", "migrate")
  FileUtils.mkdir_p(migrate_dir)

  timestamp = Time.now.strftime("%Y%m%d%H%M%S")
  filename = "migration_#{timestamp}.rb"
  filepath = File.join(migrate_dir, filename)

  table_name = tableize(suggestion[:model])

  migration_content = case suggestion[:type]
  when :uniqueness
    field = suggestion[:field] || "field"
    <<~RUBY
      class Add#{suggestion[:model]}#{field.capitalize}UniqueIndex < ActiveRecord::Migration[7.0]
        def change
          add_index :#{table_name}, :#{field}, unique: true
        end
      end
    RUBY
  when :foreign_key
    assoc = suggestion[:assoc] || "association"
    <<~RUBY
      class Add#{suggestion[:model].capitalize}#{assoc.capitalize}Index < ActiveRecord::Migration[7.0]
        def change
          add_index :#{table_name}, :#{assoc}_id
        end
      end
    RUBY
  when :index
    <<~RUBY
      class Add#{suggestion[:model].capitalize}Index < ActiveRecord::Migration[7.0]
        def change
          #{suggestion[:suggestion]}
        end
      end
    RUBY
  else
    "# No migration template available for #{suggestion[:type]}"
  end

  File.write(filepath, migration_content)
  puts "  Created db/migrate/#{filename}"
  filepath
end

.pluralize(word) ⇒ Object

Simple pluralize helper



8
9
10
11
12
# File 'lib/railsforge/database_analyzer.rb', line 8

def self.pluralize(word)
  return word + 's' unless word.end_with?('s')
  return word + 'es' if word.end_with?('sh') || word.end_with?('ch')
  word + 's'
end

Prints a single issue

Parameters:

  • issue (Hash)

    Issue details



282
283
284
285
# File 'lib/railsforge/database_analyzer.rb', line 282

def self.print_issue(issue)
  puts "\n  #{issue[:type].to_s.upcase}: #{issue[:issue]}"
  puts "    → Suggested: #{issue[:suggestion]}"
end

Prints analysis report

Parameters:

  • results (Array<Hash>)

    Analysis results



248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/railsforge/database_analyzer.rb', line 248

def self.print_report(results)
  return puts "\n✓ No database issues found!" if results.empty?

  puts "\n" + "=" * 60
  puts "DATABASE ANALYSIS REPORT"
  puts "=" * 60

  # Group by severity
  high = results.select { |r| r[:severity] == :high }
  medium = results.select { |r| r[:severity] == :medium }
  low = results.select { |r| r[:severity] == :low }

  if high.any?
    puts "\n🔴 High Priority (#{high.count}):"
    high.each { |r| print_issue(r) }
  end

  if medium.any?
    puts "\n🟡 Medium Priority (#{medium.count}):"
    medium.each { |r| print_issue(r) }
  end

  if low.any?
    puts "\n🟢 Low Priority (#{low.count}):"
    low.each { |r| print_issue(r) }
  end

  puts "\n" + "=" * 60
  puts "Total issues found: #{results.count}"
  puts "=" * 60
end

.tableize(word) ⇒ Object

Simple tableize helper



15
16
17
# File 'lib/railsforge/database_analyzer.rb', line 15

def self.tableize(word)
  pluralize(word.downcase)
end