Class: RailsVitals::Analyzers::NPlusOneAggregator
- Inherits:
-
Object
- Object
- RailsVitals::Analyzers::NPlusOneAggregator
- Defined in:
- lib/rails_vitals/analyzers/n_plus_one_aggregator.rb
Class Method Summary collapse
- .aggregate(records) ⇒ Object
- .build_suggestion(pattern) ⇒ Object
- .extract_foreign_key(sql) ⇒ Object
- .extract_table(sql) ⇒ Object
- .generic_suggestion(table) ⇒ Object
- .infer_association(table, foreign_key) ⇒ Object
- .infer_owner_model(foreign_key) ⇒ Object
- .normalize(sql) ⇒ Object
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 |