and_one 0.1.0

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.
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AndOne
4
+ # Resolves table names back to ActiveRecord models and identifies
5
+ # which association is being N+1 loaded, then suggests a fix.
6
+ module AssociationResolver
7
+ module_function
8
+
9
+ # Given a table name and a cleaned backtrace, attempt to identify
10
+ # the model, the parent association, and suggest an includes() fix.
11
+ def resolve(detection, cleaned_backtrace)
12
+ table = detection.table_name
13
+ return nil unless table
14
+
15
+ target_model = model_for_table(table)
16
+ return nil unless target_model
17
+
18
+ # Find the originating code location (first app frame in the backtrace)
19
+ origin_frame = find_origin_frame(cleaned_backtrace)
20
+
21
+ # Look for the parent model that has an association to the target
22
+ suggestion = find_association_suggestion(target_model, detection.sample_query)
23
+
24
+ Suggestion.new(
25
+ target_model: target_model,
26
+ origin_frame: origin_frame,
27
+ association_name: suggestion&.dig(:association_name),
28
+ parent_model: suggestion&.dig(:parent_model),
29
+ fix_hint: suggestion&.dig(:fix_hint),
30
+ loading_strategy: suggestion&.dig(:loading_strategy),
31
+ is_through: suggestion&.dig(:is_through) || false,
32
+ is_polymorphic: suggestion&.dig(:is_polymorphic) || false
33
+ )
34
+ end
35
+
36
+ # Maps table name -> AR model class.
37
+ # Thread-safe: uses a Mutex to protect the shared cache since multiple
38
+ # Puma threads may resolve associations concurrently.
39
+ def model_for_table(table_name)
40
+ @table_model_mutex ||= Mutex.new
41
+ @table_model_cache ||= {}
42
+
43
+ # Fast path: read from cache without lock (safe because we never delete keys,
44
+ # and Hash#[] under GVL is atomic for existing keys)
45
+ return @table_model_cache[table_name] if @table_model_cache.key?(table_name)
46
+
47
+ @table_model_mutex.synchronize do
48
+ # Double-check after acquiring lock
49
+ return @table_model_cache[table_name] if @table_model_cache.key?(table_name)
50
+
51
+ model = ActiveRecord::Base.descendants.detect do |klass|
52
+ klass.table_name == table_name
53
+ rescue
54
+ false
55
+ end
56
+
57
+ @table_model_cache[table_name] = model
58
+ model
59
+ end
60
+ end
61
+
62
+ # Finds the first backtrace frame that's in the app (not a gem/framework frame)
63
+ def find_origin_frame(cleaned_backtrace)
64
+ cleaned_backtrace&.first
65
+ end
66
+
67
+ # Tries to find which association on a parent model points to the target model,
68
+ # and extracts hints from the WHERE clause about the foreign key.
69
+ def find_association_suggestion(target_model, sql)
70
+ # Extract the foreign key column from WHERE clause
71
+ # e.g., WHERE "comments"."post_id" = ? or WHERE "comments"."post_id" IN (?)
72
+ foreign_key = extract_foreign_key(sql, target_model.table_name)
73
+
74
+ # Also try polymorphic foreign key pattern (e.g., commentable_id)
75
+ poly_foreign_key = extract_polymorphic_foreign_key(sql, target_model.table_name) unless foreign_key
76
+
77
+ effective_key = foreign_key || poly_foreign_key
78
+
79
+ # Search all models for an association whose foreign key matches
80
+ ActiveRecord::Base.descendants.each do |klass|
81
+ next if klass.abstract_class?
82
+
83
+ klass.reflect_on_all_associations.each do |assoc|
84
+ matched = if effective_key
85
+ association_matches?(assoc, target_model, effective_key)
86
+ else
87
+ # For through associations, foreign key may not be directly visible
88
+ through_association_matches?(assoc, target_model)
89
+ end
90
+
91
+ next unless matched
92
+
93
+ strategy = loading_strategy(sql, assoc.name)
94
+
95
+ return {
96
+ parent_model: klass,
97
+ association_name: assoc.name,
98
+ fix_hint: build_fix_hint(klass, assoc.name),
99
+ loading_strategy: strategy,
100
+ is_through: assoc.is_a?(ActiveRecord::Reflection::ThroughReflection),
101
+ is_polymorphic: assoc.respond_to?(:options) && !!assoc.options[:as]
102
+ }
103
+ end
104
+ rescue
105
+ next
106
+ end
107
+
108
+ nil
109
+ end
110
+
111
+ def through_association_matches?(assoc, target_model)
112
+ return false unless assoc.is_a?(ActiveRecord::Reflection::ThroughReflection)
113
+
114
+ begin
115
+ assoc.klass == target_model
116
+ rescue NameError
117
+ false
118
+ end
119
+ end
120
+
121
+ def extract_polymorphic_foreign_key(sql, table_name)
122
+ # Match patterns like: "table"."something_type" = AND "table"."something_id"
123
+ pattern = /["`]?#{Regexp.escape(table_name)}["`]?\.["`]?(\w+)_type["`]?\s*=/i
124
+ match = sql.match(pattern)
125
+ "#{match.captures.first}_id" if match
126
+ end
127
+
128
+ def extract_foreign_key(sql, table_name)
129
+ # Match patterns like: "table"."column_id" = or "table"."column_id" IN
130
+ pattern = /["`]?#{Regexp.escape(table_name)}["`]?\.["`]?(\w+_id)["`]?\s*(?:=|IN)/i
131
+ match = sql.match(pattern)
132
+ match&.captures&.first
133
+ end
134
+
135
+ def association_matches?(assoc, target_model, foreign_key)
136
+ begin
137
+ case assoc
138
+ when ActiveRecord::Reflection::ThroughReflection
139
+ # has_many :through — check if the source association points to our target
140
+ assoc.klass == target_model
141
+ when ActiveRecord::Reflection::HasManyReflection,
142
+ ActiveRecord::Reflection::HasOneReflection
143
+ if assoc.options[:as]
144
+ # Polymorphic: has_many :comments, as: :commentable
145
+ # The foreign key is like "commentable_id" and there's a "commentable_type" column
146
+ poly_fk = "#{assoc.options[:as]}_id"
147
+ assoc.klass == target_model && poly_fk == foreign_key
148
+ else
149
+ assoc.klass == target_model && assoc.foreign_key.to_s == foreign_key
150
+ end
151
+ else
152
+ false
153
+ end
154
+ rescue NameError
155
+ false
156
+ end
157
+ end
158
+
159
+ def build_fix_hint(parent_model, association_name)
160
+ "Add `.includes(:#{association_name})` to your #{parent_model.name} query"
161
+ end
162
+
163
+ # Determine the optimal loading strategy based on query patterns
164
+ def loading_strategy(sql, association_name)
165
+ # If the query has WHERE conditions on the association table, eager_load
166
+ # is better because it does a LEFT OUTER JOIN allowing WHERE filtering
167
+ if sql =~ /\bWHERE\b/i && sql =~ /\bJOIN\b/i
168
+ :eager_load
169
+ elsif sql =~ /\bWHERE\b.*\b(?:AND|OR)\b/i
170
+ # Complex WHERE — eager_load with JOIN is more efficient
171
+ :eager_load
172
+ else
173
+ # Default: preload is generally faster (separate queries, no JOIN overhead)
174
+ # includes is the safe default that lets Rails choose
175
+ :includes
176
+ end
177
+ end
178
+ end
179
+
180
+ class Suggestion
181
+ attr_reader :target_model, :origin_frame, :association_name, :parent_model,
182
+ :fix_hint, :loading_strategy, :is_through, :is_polymorphic
183
+
184
+ def initialize(target_model:, origin_frame:, association_name:, parent_model:,
185
+ fix_hint:, loading_strategy: nil, is_through: false, is_polymorphic: false)
186
+ @target_model = target_model
187
+ @origin_frame = origin_frame
188
+ @association_name = association_name
189
+ @parent_model = parent_model
190
+ @fix_hint = fix_hint
191
+ @loading_strategy = loading_strategy
192
+ @is_through = is_through
193
+ @is_polymorphic = is_polymorphic
194
+ end
195
+
196
+ def actionable?
197
+ !!@association_name
198
+ end
199
+
200
+ # Suggest strict_loading as an alternative prevention strategy
201
+ def strict_loading_hint
202
+ return nil unless actionable? && @parent_model
203
+
204
+ assoc_type = if @is_through
205
+ "has_many :#{@association_name}, through: ..."
206
+ else
207
+ "has_many :#{@association_name}"
208
+ end
209
+
210
+ "Or prevent at the model level: `#{assoc_type}, strict_loading: true` in #{@parent_model.name}"
211
+ end
212
+
213
+ # Suggest the optimal loading strategy when it differs from plain .includes
214
+ def loading_strategy_hint
215
+ return nil unless actionable? && @loading_strategy
216
+
217
+ case @loading_strategy
218
+ when :eager_load
219
+ "Consider `.eager_load(:#{@association_name})` instead — your query filters on the association, so a JOIN is more efficient"
220
+ when :preload
221
+ "Consider `.preload(:#{@association_name})` — separate queries avoid JOIN overhead for simple loading"
222
+ else
223
+ nil # :includes is the default, no extra hint needed
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AndOne
4
+ # Auto-scans for N+1 queries during `rails console` sessions.
5
+ # Each console command is wrapped in an N+1 scan, and warnings
6
+ # are printed inline after the result.
7
+ #
8
+ # Activated automatically by the Railtie in development, or manually:
9
+ #
10
+ # AndOne::Console.activate!
11
+ #
12
+ # To deactivate:
13
+ # AndOne::Console.deactivate!
14
+ #
15
+ module Console
16
+ class << self
17
+ def activate!
18
+ return if active?
19
+
20
+ @active = true
21
+ @previous_raise = AndOne.raise_on_detect
22
+ # Never raise in console — always warn inline
23
+ AndOne.raise_on_detect = false
24
+
25
+ start_scan
26
+ install_hook
27
+ end
28
+
29
+ def deactivate!
30
+ return unless active?
31
+
32
+ finish_scan
33
+ remove_hook
34
+ AndOne.raise_on_detect = @previous_raise
35
+ @active = false
36
+ end
37
+
38
+ def active?
39
+ @active == true
40
+ end
41
+
42
+ private
43
+
44
+ def start_scan
45
+ return if AndOne.scanning?
46
+
47
+ AndOne.scan # Start without a block — manual finish later
48
+ end
49
+
50
+ def finish_scan
51
+ AndOne.finish if AndOne.scanning?
52
+ rescue
53
+ # Don't let cleanup errors interrupt the console
54
+ nil
55
+ end
56
+
57
+ # Install an IRB/Pry hook that finishes the current scan after each
58
+ # command and starts a fresh one.
59
+ def install_hook
60
+ if defined?(::IRB)
61
+ install_irb_hook
62
+ elsif defined?(::Pry)
63
+ install_pry_hook
64
+ end
65
+ end
66
+
67
+ def remove_hook
68
+ if defined?(::IRB) && @irb_hook_installed
69
+ remove_irb_hook
70
+ elsif defined?(::Pry) && @pry_hook_installed
71
+ remove_pry_hook
72
+ end
73
+ end
74
+
75
+ def install_irb_hook
76
+ return if @irb_hook_installed
77
+
78
+ @irb_hook_installed = true
79
+
80
+ # IRB in Rails 7.1+ uses IRB::Context#evaluate with hooks
81
+ # We hook into the SIGINT-safe eval output via an around_eval approach
82
+ if defined?(::IRB::Context)
83
+ ::IRB::Context.prepend(IrbContextPatch)
84
+ end
85
+ end
86
+
87
+ def remove_irb_hook
88
+ @irb_hook_installed = false
89
+ # The prepend can't be removed, but IrbContextPatch checks active?
90
+ end
91
+
92
+ def install_pry_hook
93
+ return if @pry_hook_installed
94
+
95
+ @pry_hook_installed = true
96
+
97
+ ::Pry.hooks.add_hook(:after_eval, :and_one_console) do |result, _pry|
98
+ AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
99
+ end
100
+ end
101
+
102
+ def remove_pry_hook
103
+ if defined?(::Pry) && ::Pry.hooks
104
+ ::Pry.hooks.delete_hook(:after_eval, :and_one_console)
105
+ end
106
+ @pry_hook_installed = false
107
+ end
108
+
109
+ # Finish the current scan (reporting any N+1s), then start a fresh one.
110
+ def cycle_scan
111
+ finish_scan
112
+ start_scan
113
+ end
114
+ end
115
+
116
+ # Prepended into IRB::Context to hook after each evaluation.
117
+ module IrbContextPatch
118
+ def evaluate(...)
119
+ result = super
120
+ AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
121
+ result
122
+ rescue => e
123
+ AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
124
+ raise
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module AndOne
6
+ # Represents a single N+1 detection: the repeated queries, their call site, and metadata.
7
+ class Detection
8
+ attr_reader :queries, :caller_locations, :count, :adapter
9
+
10
+ def initialize(queries:, caller_locations:, count:, adapter: nil)
11
+ @queries = queries
12
+ @caller_locations = caller_locations
13
+ @count = count
14
+ @adapter = adapter
15
+ end
16
+
17
+ # Returns the SQL of the first query as the representative example
18
+ def sample_query
19
+ @queries.first
20
+ end
21
+
22
+ # Attempt to extract the table name from the repeated query
23
+ def table_name
24
+ @table_name ||= extract_table_name(sample_query)
25
+ end
26
+
27
+ # A stable fingerprint for this detection, based on the query shape and
28
+ # the target table. Independent of call site so the same N+1 pattern
29
+ # produces the same fingerprint regardless of where it's triggered.
30
+ # For location-specific ignoring, use `path:` rules in .and_one_ignore.
31
+ def fingerprint
32
+ @fingerprint ||= begin
33
+ sql_fp = Fingerprint.generate(sample_query)
34
+ Digest::SHA256.hexdigest("#{sql_fp}:#{table_name}")[0, 12]
35
+ end
36
+ end
37
+
38
+ # The raw caller strings (before backtrace cleaning)
39
+ def raw_caller_strings
40
+ @raw_caller_strings ||= caller_locations.map(&:to_s)
41
+ end
42
+
43
+ # The first frame in the call stack that is application code
44
+ # (not a gem, not ruby stdlib, not and_one itself)
45
+ def origin_frame
46
+ @origin_frame ||= find_origin_frame
47
+ end
48
+
49
+ # The frame where the AR relation/collection was likely loaded or iterated.
50
+ # This is the best place to add .includes().
51
+ def fix_location
52
+ @fix_location ||= find_fix_location
53
+ end
54
+
55
+ private
56
+
57
+ def extract_table_name(sql)
58
+ if sql =~ /\bFROM\s+["`]?(\w+)["`]?/i
59
+ $1
60
+ end
61
+ end
62
+
63
+ def find_origin_frame
64
+ raw_caller_strings.detect { |frame| app_frame?(frame) }
65
+ end
66
+
67
+ # Walk the backtrace looking for the frame that set up the iteration.
68
+ # In a typical N+1, the stack looks like:
69
+ # - AR internals (loading the association)
70
+ # - The line calling .to_a / accessing the association (origin_frame)
71
+ # - The .each / .map / .find_each iteration
72
+ # - The controller/view/job that built the relation
73
+ #
74
+ # We want the outermost app frame that's near an AR relation method,
75
+ # or failing that, the second app frame (the caller OF the origin).
76
+ def find_fix_location
77
+ app_frames = raw_caller_strings.select { |f| app_frame?(f) }
78
+ return nil if app_frames.empty?
79
+
80
+ # The first app frame is where the association is accessed (inside the loop).
81
+ # The second app frame is often where the loop itself is, or the controller action.
82
+ # If they're on the same file+line, look further up.
83
+ if app_frames.size >= 2 && app_frames[0] != app_frames[1]
84
+ app_frames[1]
85
+ else
86
+ app_frames.detect { |f| f != app_frames[0] } || app_frames.first
87
+ end
88
+ end
89
+
90
+ def app_frame?(frame)
91
+ # Not a gem
92
+ !frame.include?("/gems/") &&
93
+ # Not ruby stdlib / core
94
+ !frame.include?("/ruby/") &&
95
+ # Not and_one's own lib code
96
+ !frame.include?("lib/and_one/") &&
97
+ # Not <internal: or (eval) type frames
98
+ !frame.start_with?("<internal:") &&
99
+ !frame.include?("(eval)")
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AndOne
4
+ # Subscribes to ActiveRecord SQL notifications and detects N+1 query patterns.
5
+ # Each instance tracks queries for a single request/block scope.
6
+ class Detector
7
+ DEFAULT_ALLOW_LIST = [
8
+ /active_record\/relation.*preload_associations/,
9
+ /active_record\/validations\/uniqueness/
10
+ ].freeze
11
+
12
+ attr_reader :detections
13
+
14
+ def initialize(allow_stack_paths: [], ignore_queries: [], min_n_queries: 2)
15
+ @allow_stack_paths = allow_stack_paths
16
+ @ignore_queries = ignore_queries
17
+ @min_n_queries = min_n_queries
18
+
19
+ # location_key => count
20
+ @query_counter = Hash.new(0)
21
+ # location_key => [sql, ...]
22
+ @query_holder = Hash.new { |h, k| h[k] = [] }
23
+ # location_key => caller_locations
24
+ @query_callers = {}
25
+ # location_key => additional metadata from AR notification
26
+ @query_metadata = {}
27
+
28
+ @detections = []
29
+
30
+ subscribe
31
+ end
32
+
33
+ def finish
34
+ unsubscribe
35
+ analyze
36
+ @detections
37
+ end
38
+
39
+ private
40
+
41
+ def subscribe
42
+ # IMPORTANT: The notification callback fires on whatever thread triggered the SQL,
43
+ # NOT necessarily the thread that created this Detector. We must look up the
44
+ # current thread's detector (via Thread.current) to avoid cross-thread contamination.
45
+ # We store our object_id so the callback can verify it's writing to the correct instance.
46
+ detector_id = object_id
47
+
48
+ @subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
49
+ next unless AndOne.scanning? && !AndOne.paused?
50
+
51
+ # Only record if the current thread's detector is THIS detector.
52
+ # Under Puma, multiple Detectors may be subscribed simultaneously;
53
+ # each must only process its own thread's queries.
54
+ current_detector = Thread.current[:and_one_detector]
55
+ next unless current_detector&.object_id == detector_id
56
+
57
+ sql = payload[:sql]
58
+ name = payload[:name]
59
+
60
+ next if name == "SCHEMA"
61
+ next if !sql.include?("SELECT")
62
+ next if payload[:cached]
63
+ next if current_detector.send(:ignored?, sql)
64
+
65
+ current_detector.send(:record_query, sql, payload)
66
+ end
67
+ end
68
+
69
+ def unsubscribe
70
+ ActiveSupport::Notifications.unsubscribe(@subscriber) if @subscriber
71
+ @subscriber = nil
72
+ end
73
+
74
+ def record_query(sql, payload)
75
+ locations = caller_locations
76
+ location_key = location_fingerprint(locations)
77
+
78
+ @query_counter[location_key] += 1
79
+ @query_holder[location_key] << sql
80
+
81
+ # Only store caller on the second occurrence to save memory
82
+ if @query_counter[location_key] >= 2
83
+ @query_callers[location_key] = locations
84
+ @query_metadata[location_key] ||= {
85
+ connection_adapter: adapter_name,
86
+ type_casted_binds: payload[:type_casted_binds]
87
+ }
88
+ end
89
+ end
90
+
91
+ def location_fingerprint(locations)
92
+ # Build a hash from the call stack to group identical call paths
93
+ key = 0
94
+ locations.each do |loc|
95
+ key = key ^ loc.path.hash ^ loc.lineno
96
+ end
97
+ key
98
+ end
99
+
100
+ def adapter_name
101
+ ActiveRecord::Base.connection_db_config.adapter
102
+ rescue
103
+ "unknown"
104
+ end
105
+
106
+ def analyze
107
+ @query_counter.each do |location_key, count|
108
+ next if count < @min_n_queries
109
+
110
+ queries = @query_holder[location_key]
111
+ callers = @query_callers[location_key]
112
+ metadata = @query_metadata[location_key] || {}
113
+
114
+ next unless callers
115
+
116
+ # Group by fingerprint to confirm they're actually the same query shape
117
+ grouped = queries.group_by { |q| Fingerprint.generate(q) }
118
+ repeated = grouped.values.select { |group| group.size >= @min_n_queries }
119
+
120
+ next if repeated.empty?
121
+
122
+ caller_strings = callers.map(&:to_s)
123
+ all_allow = DEFAULT_ALLOW_LIST + @allow_stack_paths
124
+ next if caller_strings.any? { |frame| all_allow.any? { |pattern| frame.match?(pattern) } }
125
+
126
+ repeated.each do |query_group|
127
+ @detections << Detection.new(
128
+ queries: query_group,
129
+ caller_locations: callers,
130
+ count: query_group.size,
131
+ adapter: metadata[:connection_adapter]
132
+ )
133
+ end
134
+ end
135
+ end
136
+
137
+ def ignored?(sql)
138
+ @ignore_queries.any? { |pattern| pattern === sql }
139
+ end
140
+ end
141
+ end