Module: AndOne::AssociationResolver
- Defined in:
- lib/and_one/association_resolver.rb
Overview
Resolves table names back to ActiveRecord models and identifies which association is being N+1 loaded, then suggests a fix.
Class Method Summary collapse
- .association_matches?(assoc, target_model, foreign_key) ⇒ Boolean
- .build_fix_hint(parent_model, association_name) ⇒ Object
- .extract_foreign_key(sql, table_name) ⇒ Object
- .extract_polymorphic_foreign_key(sql, table_name) ⇒ Object
-
.find_association_suggestion(target_model, sql) ⇒ Object
Tries to find which association on a parent model points to the target model, and extracts hints from the WHERE clause about the foreign key.
-
.find_origin_frame(cleaned_backtrace) ⇒ Object
Finds the first backtrace frame that's in the app (not a gem/framework frame).
-
.loading_strategy(sql, _association_name) ⇒ Object
Determine the optimal loading strategy based on query patterns.
-
.model_for_table(table_name) ⇒ Object
Maps table name -> AR model class.
-
.resolve(detection, cleaned_backtrace) ⇒ Object
Given a table name and a cleaned backtrace, attempt to identify the model, the parent association, and suggest an includes() fix.
- .through_association_matches?(assoc, target_model) ⇒ Boolean
Class Method Details
.association_matches?(assoc, target_model, foreign_key) ⇒ Boolean
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 |
# File 'lib/and_one/association_resolver.rb', line 135 def association_matches?(assoc, target_model, foreign_key) case assoc when ActiveRecord::Reflection::ThroughReflection # has_many :through — check if the source association points to our target assoc.klass == target_model when ActiveRecord::Reflection::HasManyReflection, ActiveRecord::Reflection::HasOneReflection if assoc.[:as] # Polymorphic: has_many :comments, as: :commentable # The foreign key is like "commentable_id" and there's a "commentable_type" column poly_fk = "#{assoc.options[:as]}_id" assoc.klass == target_model && poly_fk == foreign_key else assoc.klass == target_model && assoc.foreign_key.to_s == foreign_key end else false end rescue NameError false end |
.build_fix_hint(parent_model, association_name) ⇒ Object
157 158 159 |
# File 'lib/and_one/association_resolver.rb', line 157 def build_fix_hint(parent_model, association_name) "Add `.includes(:#{association_name})` to your #{parent_model.name} query" end |
.extract_foreign_key(sql, table_name) ⇒ Object
128 129 130 131 132 133 |
# File 'lib/and_one/association_resolver.rb', line 128 def extract_foreign_key(sql, table_name) # Match patterns like: "table"."column_id" = or "table"."column_id" IN pattern = /["`]?#{Regexp.escape(table_name)}["`]?\.["`]?(\w+_id)["`]?\s*(?:=|IN)/i match = sql.match(pattern) match&.captures&.first end |
.extract_polymorphic_foreign_key(sql, table_name) ⇒ Object
121 122 123 124 125 126 |
# File 'lib/and_one/association_resolver.rb', line 121 def extract_polymorphic_foreign_key(sql, table_name) # Match patterns like: "table"."something_type" = AND "table"."something_id" pattern = /["`]?#{Regexp.escape(table_name)}["`]?\.["`]?(\w+)_type["`]?\s*=/i match = sql.match(pattern) "#{match.captures.first}_id" if match end |
.find_association_suggestion(target_model, sql) ⇒ Object
Tries to find which association on a parent model points to the target model, and extracts hints from the WHERE clause about the foreign key.
69 70 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 102 103 104 105 106 107 108 109 |
# File 'lib/and_one/association_resolver.rb', line 69 def find_association_suggestion(target_model, sql) # Extract the foreign key column from WHERE clause # e.g., WHERE "comments"."post_id" = ? or WHERE "comments"."post_id" IN (?) foreign_key = extract_foreign_key(sql, target_model.table_name) # Also try polymorphic foreign key pattern (e.g., commentable_id) poly_foreign_key = extract_polymorphic_foreign_key(sql, target_model.table_name) unless foreign_key effective_key = foreign_key || poly_foreign_key # Search all models for an association whose foreign key matches ActiveRecord::Base.descendants.each do |klass| next if klass.abstract_class? klass.reflect_on_all_associations.each do |assoc| matched = if effective_key association_matches?(assoc, target_model, effective_key) else # For through associations, foreign key may not be directly visible through_association_matches?(assoc, target_model) end next unless matched strategy = loading_strategy(sql, assoc.name) return { parent_model: klass, association_name: assoc.name, fix_hint: build_fix_hint(klass, assoc.name), loading_strategy: strategy, is_through: assoc.is_a?(ActiveRecord::Reflection::ThroughReflection), is_polymorphic: assoc.respond_to?(:options) && !assoc.[:as].nil? } end rescue StandardError next end nil end |
.find_origin_frame(cleaned_backtrace) ⇒ Object
Finds the first backtrace frame that's in the app (not a gem/framework frame)
63 64 65 |
# File 'lib/and_one/association_resolver.rb', line 63 def find_origin_frame(cleaned_backtrace) cleaned_backtrace&.first end |
.loading_strategy(sql, _association_name) ⇒ Object
Determine the optimal loading strategy based on query patterns
162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/and_one/association_resolver.rb', line 162 def loading_strategy(sql, _association_name) # If the query has WHERE conditions on the association table, eager_load # is better because it does a LEFT OUTER JOIN allowing WHERE filtering if sql =~ /\bWHERE\b/i && (sql =~ /\bJOIN\b/i || sql =~ /\b(?:AND|OR)\b/i) :eager_load else # Default: preload is generally faster (separate queries, no JOIN overhead) # includes is the safe default that lets Rails choose :includes end end |
.model_for_table(table_name) ⇒ Object
Maps table name -> AR model class. Thread-safe: uses a Mutex to protect the shared cache since multiple Puma threads may resolve associations concurrently.
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/and_one/association_resolver.rb', line 39 def model_for_table(table_name) @table_model_mutex ||= Mutex.new @table_model_cache ||= {} # Fast path: read from cache without lock (safe because we never delete keys, # and Hash#[] under GVL is atomic for existing keys) return @table_model_cache[table_name] if @table_model_cache.key?(table_name) @table_model_mutex.synchronize do # Double-check after acquiring lock return @table_model_cache[table_name] if @table_model_cache.key?(table_name) model = ActiveRecord::Base.descendants.detect do |klass| klass.table_name == table_name rescue StandardError false end @table_model_cache[table_name] = model model end end |
.resolve(detection, cleaned_backtrace) ⇒ Object
Given a table name and a cleaned backtrace, attempt to identify the model, the parent association, and suggest an includes() fix.
11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
# File 'lib/and_one/association_resolver.rb', line 11 def resolve(detection, cleaned_backtrace) table = detection.table_name return nil unless table target_model = model_for_table(table) return nil unless target_model # Find the originating code location (first app frame in the backtrace) origin_frame = find_origin_frame(cleaned_backtrace) # Look for the parent model that has an association to the target suggestion = find_association_suggestion(target_model, detection.sample_query) Suggestion.new( target_model: target_model, origin_frame: origin_frame, association_name: suggestion&.dig(:association_name), parent_model: suggestion&.dig(:parent_model), fix_hint: suggestion&.dig(:fix_hint), loading_strategy: suggestion&.dig(:loading_strategy), is_through: suggestion&.dig(:is_through) || false, is_polymorphic: suggestion&.dig(:is_polymorphic) || false ) end |
.through_association_matches?(assoc, target_model) ⇒ Boolean
111 112 113 114 115 116 117 118 119 |
# File 'lib/and_one/association_resolver.rb', line 111 def through_association_matches?(assoc, target_model) return false unless assoc.is_a?(ActiveRecord::Reflection::ThroughReflection) begin assoc.klass == target_model rescue NameError false end end |