Class: RailsVitals::Analyzers::NPlusOneAggregator

Inherits:
Object
  • Object
show all
Defined in:
lib/rails_vitals/analyzers/n_plus_one_aggregator.rb

Class Method Summary collapse

Class Method Details

.aggregate(records) ⇒ Object



4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/rails_vitals/analyzers/n_plus_one_aggregator.rb', line 4

def self.aggregate(records)
  pattern_data = Hash.new do |h, k|
    h[k] = {
      pattern: k,
      occurrences: 0,
      endpoints: Hash.new(0),
      table: nil,
      foreign_key: nil
    }
  end

  records.each do |record|
    next if record.n_plus_one_patterns.empty?

    record.n_plus_one_patterns.each do |sql, count|
      normalized = normalize(sql)
      Rails.logger.debug "#{self.name}: Processing SQL: #{sql} → normalized: #{normalized}"

      pattern_data[normalized][:occurrences] += count
      pattern_data[normalized][:endpoints][record.endpoint] += 1
      pattern_data[normalized][:table] ||= extract_table(sql)
      pattern_data[normalized][:foreign_key] ||= extract_foreign_key(sql)
    end
  end

  pattern_data
    .values
    .sort_by { |p| -p[:occurrences] }
    .map do |p|
      p[:endpoints] = p[:endpoints].to_h
      p.merge(fix_suggestion: build_suggestion(p))
    end
end

.build_suggestion(pattern) ⇒ Object



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/rails_vitals/analyzers/n_plus_one_aggregator.rb', line 59

def self.build_suggestion(pattern)
  table = pattern[:table]
  foreign_key = pattern[:foreign_key]

  return generic_suggestion(table) unless table && foreign_key

  # Map foreign_key back to association
  owner_model = infer_owner_model(foreign_key)
  assoc_name  = infer_association(table, foreign_key)

  if owner_model && assoc_name
    {
      code: "#{owner_model}.includes(:#{assoc_name})",
      description: "Eager load :#{assoc_name} on #{owner_model} to eliminate this N+1",
      owner: owner_model,
      association: assoc_name
    }
  else
    generic_suggestion(table)
  end
end

.extract_foreign_key(sql) ⇒ Object



54
55
56
57
# File 'lib/rails_vitals/analyzers/n_plus_one_aggregator.rb', line 54

def self.extract_foreign_key(sql)
  clean = sql.gsub('\\"', '"')
  clean.match(/WHERE\s+"?\w+"?\."?(\w+_id)"\s*=/i)&.captures&.first
end

.extract_table(sql) ⇒ Object



49
50
51
52
# File 'lib/rails_vitals/analyzers/n_plus_one_aggregator.rb', line 49

def self.extract_table(sql)
  clean = sql.gsub('\\"', '"')
  clean.match(/FROM\s+"?(\w+)"?/i)&.captures&.first
end

.generic_suggestion(table) ⇒ Object



106
107
108
109
110
111
112
113
# File 'lib/rails_vitals/analyzers/n_plus_one_aggregator.rb', line 106

def self.generic_suggestion(table)
  {
    code: "includes(:#{table&.singularize})",
    description: "Use includes(), eager_load(), or preload() to batch this query",
    owner: nil,
    association: table&.singularize
  }
end

.infer_association(table, foreign_key) ⇒ Object



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/rails_vitals/analyzers/n_plus_one_aggregator.rb', line 86

def self.infer_association(table, foreign_key)
  # table = "posts", foreign_key = "user_id"
  # → association :posts on User
  owner_class_name = foreign_key.sub(/_id$/, "").classify

  begin
    owner_class = owner_class_name.constantize
    return nil unless owner_class < ActiveRecord::Base

    # Find association on owner that points to this table
    assoc = owner_class.reflect_on_all_associations.find do |r|
      r.klass.table_name == table rescue false
    end

    assoc&.name
  rescue NameError
    nil
  end
end

.infer_owner_model(foreign_key) ⇒ Object



81
82
83
84
# File 'lib/rails_vitals/analyzers/n_plus_one_aggregator.rb', line 81

def self.infer_owner_model(foreign_key)
  # foreign_key = "user_id" → owner is "User"
  foreign_key.sub(/_id$/, "").classify
end

.normalize(sql) ⇒ Object



40
41
42
43
44
45
46
47
# File 'lib/rails_vitals/analyzers/n_plus_one_aggregator.rb', line 40

def self.normalize(sql)
  sql
    .gsub('\\"', '"') # unescape stored escaped quotes
    .gsub(/\b\d+\b/, "?")
    .gsub(/'[^']*'/, "?")
    .gsub(/\s+/, " ")
    .strip
end