eager_eye 1.0.2 → 1.0.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a37ac20117548282deb586bacdab0deab006b41581efa0810d3b64ffdcf1465
4
- data.tar.gz: fa2dc1e3b2fa8582c0b48606ee88a75de9f7d6638593ea3404493b1383ad9512
3
+ metadata.gz: f7ebed3daa9046b0b6bbda1cbb7a9cce182af014ac31334973b4d98a5a1059bc
4
+ data.tar.gz: 61e220f6737089f52ab495d422cac8b91bf4720d6551c6a42abad7801c7d3c79
5
5
  SHA512:
6
- metadata.gz: 8d4c41a777a9d79da47c9b0a5ce467acd951fdf1e72682e0ea235b5cc10329c403177c039ee7beb42a2f0b025796e6484d5e412f0e7697ad66902961f1348c2c
7
- data.tar.gz: e293e6c338bc7413d922e3d0daad30e0033477b7819f5b95b551dd8aebe53d0efd350615ece1ff6cadabc03267fcce7aaa08814deb8fb16620f0974daa41a597
6
+ metadata.gz: 198f7d4c064f940a73e2ac41f401869274482f1388f9031f405c89cdeddd3e8e1cf829f8d107c7c3fa1a88fa1a5de07fe44a3d08acf4cb269aa063ebfafae729
7
+ data.tar.gz: 814ce257bfc024ca1d83b61cddd3df9b56ed877dfb40d9eaf5564c951bebf11f4351d0c4965ff1510509f62aad3f97af8f0e4933b5a23b4daf048b186d5c748b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.4] - 2025-12-21
11
+
12
+ ### Fixed
13
+
14
+ - Fixed false positive N+1 warnings when associations are preloaded via `includes`, `preload`, or `eager_load` on a separate line
15
+ - Now correctly tracks variable assignments with preload methods (e.g., `posts = Post.includes(:author)`)
16
+ - Supports both local variables and instance variables
17
+ - Works with all three preload methods: `includes`, `preload`, `eager_load`
18
+
19
+ ## [1.0.3] - 2025-12-20
20
+
21
+ ### Fixed
22
+
23
+ - Only flag queries in callbacks where the iteration variable is the receiver (reduces false positives)
24
+ - Updated README callback query documentation to accurately reflect current behavior
25
+
26
+ ## [1.0.2] - 2025-12-19
27
+
28
+ ### Fixed
29
+
30
+ - Only detect queries inside iterations in callback detector
31
+
10
32
  ## [1.0.1] - 2025-12-16
11
33
 
12
34
  ### Changed
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # EagerEye
2
2
 
3
3
  [![CI](https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml/badge.svg)](https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml)
4
- [![Gem Version](https://img.shields.io/badge/gem-v1.0.1-red.svg)](https://rubygems.org/gems/eager_eye)
4
+ [![Gem Version](https://img.shields.io/badge/gem-v1.0.4-red.svg)](https://rubygems.org/gems/eager_eye)
5
5
  [![Coverage](https://img.shields.io/badge/coverage-95%25-brightgreen.svg)](https://github.com/hamzagedikkaya/eager_eye)
6
6
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg)](https://www.ruby-lang.org/)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -99,11 +99,21 @@ posts.each do |post|
99
99
  post.comments.count # Another query for each post!
100
100
  end
101
101
 
102
- # Good - Eager load associations
102
+ # Good - Eager load associations (chained)
103
103
  posts.includes(:author, :comments).each do |post|
104
104
  post.author.name # No additional query
105
105
  post.comments.count # No additional query
106
106
  end
107
+
108
+ # Good - Eager load on separate line (also detected correctly!)
109
+ @posts = Post.includes(:author)
110
+ @posts.each do |post|
111
+ post.author.name # No warning - EagerEye tracks the preload
112
+ end
113
+
114
+ # Also works with preload and eager_load
115
+ posts = Post.preload(:comments)
116
+ posts.each { |post| post.comments.size } # No warning
107
117
  ```
108
118
 
109
119
  ### 2. Serializer Nesting (N+1 in serializers)
@@ -209,42 +219,45 @@ user.posts_count # Just reads the column
209
219
 
210
220
  ### 6. Callback Query Detection
211
221
 
212
- Detects database queries and iterations inside ActiveRecord callbacks. These become performance disasters during bulk operations.
222
+ Detects N+1 patterns inside ActiveRecord callbacks - specifically iterations that execute queries on each loop.
213
223
 
214
224
  ```ruby
215
- # Bad - Queries in callbacks
216
- class Article < ApplicationRecord
217
- after_save :recalculate_stats
225
+ # Bad - N+1 in callback (DETECTED)
226
+ class Order < ApplicationRecord
227
+ after_create :notify_subscribers
218
228
 
219
- def recalculate_stats
220
- author.articles.published.count # Runs on EVERY save
221
- category.update_article_count! # Another query on EVERY save
229
+ def notify_subscribers
230
+ customer.followers.each do |follower| # Error: Iteration in callback
231
+ follower.notifications.create!(...) # Warning: Query on iteration variable
232
+ end
222
233
  end
223
234
  end
224
235
 
225
- # Disaster scenario
226
- Article.import(1000.times.map { |i| { title: "Post #{i}" } })
227
- # = 2000+ queries from callbacks!
236
+ # OK - Single query in callback (NOT flagged - not N+1)
237
+ class Article < ApplicationRecord
238
+ after_save :update_stats
228
239
 
229
- # Bad - N+1 in callback
230
- class Order < ApplicationRecord
231
- after_create :notify_subscribers
240
+ def update_stats
241
+ author.articles.count # Single query, acceptable
242
+ end
243
+ end
232
244
 
233
- def notify_subscribers
234
- customer.followers.each do |follower| # N+1!
235
- NotificationMailer.new_order(follower).deliver_later
245
+ # OK - Query not on iteration variable (NOT flagged)
246
+ class Post < ApplicationRecord
247
+ after_save :process_items
248
+
249
+ def process_items
250
+ items.each do |item|
251
+ OtherModel.where(name: item.name).first # OtherModel is receiver, not item
236
252
  end
237
253
  end
238
254
  end
239
255
 
240
- # Good - Use conditional callbacks
241
- after_save :recalculate_stats, if: :should_recalculate?
242
-
243
- # Good - Move to background job
244
- after_commit :schedule_stats_update, on: :create
256
+ # Good - Move iterations to background job
257
+ after_commit :schedule_notifications, on: :create
245
258
 
246
- def schedule_stats_update
247
- RecalculateStatsJob.perform_later(id)
259
+ def schedule_notifications
260
+ NotifySubscribersJob.perform_later(id)
248
261
  end
249
262
  ```
250
263
 
@@ -106,8 +106,9 @@ module EagerEye
106
106
  return unless node.is_a?(Parser::AST::Node)
107
107
 
108
108
  if iteration_block?(node)
109
+ block_var = extract_block_variable(node)
109
110
  add_iteration_issue(node, method_name, callback_type)
110
- find_query_calls_in_block(node, method_name, callback_type)
111
+ find_query_calls_in_block(node, method_name, callback_type, block_var) if block_var
111
112
  end
112
113
 
113
114
  node.children.each do |child|
@@ -115,13 +116,15 @@ module EagerEye
115
116
  end
116
117
  end
117
118
 
118
- def find_query_calls_in_block(node, method_name, callback_type)
119
+ def find_query_calls_in_block(node, method_name, callback_type, block_var)
119
120
  return unless node.is_a?(Parser::AST::Node)
120
121
 
121
- add_query_issue(node, method_name, callback_type) if query_call?(node)
122
+ if query_call?(node) && receiver_chain_starts_with?(node.children[0], block_var)
123
+ add_query_issue(node, method_name, callback_type)
124
+ end
122
125
 
123
126
  node.children.each do |child|
124
- find_query_calls_in_block(child, method_name, callback_type)
127
+ find_query_calls_in_block(child, method_name, callback_type, block_var)
125
128
  end
126
129
  end
127
130
 
@@ -163,6 +166,29 @@ module EagerEye
163
166
  suggestion: "Avoid iterations in callbacks. Use background jobs for bulk operations"
164
167
  )
165
168
  end
169
+
170
+ def extract_block_variable(block_node)
171
+ args_node = block_node.children[1]
172
+ return nil unless args_node&.type == :args
173
+
174
+ first_arg = args_node.children[0]
175
+ return nil unless first_arg&.type == :arg
176
+
177
+ first_arg.children[0]
178
+ end
179
+
180
+ def receiver_chain_starts_with?(node, block_var)
181
+ return false unless node.is_a?(Parser::AST::Node)
182
+
183
+ case node.type
184
+ when :lvar
185
+ node.children[0] == block_var
186
+ when :send
187
+ receiver_chain_starts_with?(node.children[0], block_var)
188
+ else
189
+ false
190
+ end
191
+ end
166
192
  end
167
193
  end
168
194
  end
@@ -4,6 +4,7 @@ module EagerEye
4
4
  module Detectors
5
5
  class LoopAssociation < Base
6
6
  ITERATION_METHODS = %i[each map collect select find find_all reject filter filter_map flat_map].freeze
7
+ PRELOAD_METHODS = %i[includes preload eager_load].freeze
7
8
 
8
9
  # Common singular association names (belongs_to pattern)
9
10
  SINGULAR_ASSOCIATIONS = %w[
@@ -23,12 +24,10 @@ module EagerEye
23
24
 
24
25
  # Methods that should NOT be treated as associations
25
26
  EXCLUDED_METHODS = %i[
26
- id to_s to_h to_a to_json to_xml inspect class object_id
27
- nil? blank? present? empty? any? none? size count length
28
- save save! update update! destroy destroy! delete delete!
29
- valid? invalid? errors new? persisted? changed? frozen?
30
- name title body content text description value key type status state
31
- created_at updated_at deleted_at
27
+ id to_s to_h to_a to_json to_xml inspect class object_id nil? blank? present? empty?
28
+ any? none? size count length save save! update update! destroy destroy! delete delete!
29
+ valid? invalid? errors new? persisted? changed? frozen? name title body content text
30
+ description value key type status state created_at updated_at deleted_at
32
31
  ].freeze
33
32
 
34
33
  def self.detector_name
@@ -40,6 +39,9 @@ module EagerEye
40
39
 
41
40
  issues = []
42
41
 
42
+ # Build a map of variable names to their preloaded associations
43
+ @variable_preloads = build_variable_preloads_map(ast)
44
+
43
45
  traverse_ast(ast) do |node|
44
46
  next unless iteration_block?(node)
45
47
 
@@ -49,10 +51,14 @@ module EagerEye
49
51
  block_body = node.children[2]
50
52
  next unless block_body
51
53
 
52
- # Check if the collection already has includes
54
+ # Check if the collection already has includes (both chained and from variable assignment)
53
55
  collection_node = node.children[0]
54
56
  included_associations = extract_included_associations(collection_node)
55
57
 
58
+ # Also check if the collection comes from a variable that was assigned with preloads
59
+ variable_preloads = extract_variable_preloads(collection_node)
60
+ included_associations.merge(variable_preloads)
61
+
56
62
  find_association_calls(block_body, block_var, file_path, issues, included_associations)
57
63
  end
58
64
 
@@ -86,11 +92,11 @@ module EagerEye
86
92
  included = Set.new
87
93
  return included unless collection_node&.type == :send
88
94
 
89
- # Traverse through chained method calls to find includes()
95
+ # Traverse through chained method calls to find includes/preload/eager_load
90
96
  current = collection_node
91
97
  while current&.type == :send
92
98
  method_name = current.children[1]
93
- extract_includes_from_method(current, included) if method_name == :includes
99
+ extract_includes_from_method(current, included) if PRELOAD_METHODS.include?(method_name)
94
100
 
95
101
  current = current.children[0]
96
102
  end
@@ -98,6 +104,52 @@ module EagerEye
98
104
  included
99
105
  end
100
106
 
107
+ def build_variable_preloads_map(ast)
108
+ preloads_map = {}
109
+
110
+ traverse_ast(ast) do |node|
111
+ case node.type
112
+ when :lvasgn
113
+ record_variable_preloads(preloads_map, :lvar, node)
114
+ when :ivasgn
115
+ record_variable_preloads(preloads_map, :ivar, node)
116
+ end
117
+ end
118
+
119
+ preloads_map
120
+ end
121
+
122
+ def record_variable_preloads(preloads_map, var_type, node)
123
+ var_name = node.children[0]
124
+ value_node = node.children[1]
125
+ return unless value_node
126
+
127
+ preloaded = extract_included_associations(value_node)
128
+ preloads_map[[var_type, var_name]] = preloaded unless preloaded.empty?
129
+ end
130
+
131
+ def extract_variable_preloads(collection_node)
132
+ preloads = Set.new
133
+ return preloads unless @variable_preloads
134
+
135
+ key = variable_key_for_node(collection_node)
136
+ merge_preloads_for_key(preloads, key) if key
137
+
138
+ preloads
139
+ end
140
+
141
+ def variable_key_for_node(node)
142
+ case node&.type
143
+ when :lvar then [:lvar, node.children[0]]
144
+ when :ivar then [:ivar, node.children[0]]
145
+ when :send then variable_key_for_node(node.children[0])
146
+ end
147
+ end
148
+
149
+ def merge_preloads_for_key(preloads, key)
150
+ preloads.merge(@variable_preloads[key]) if @variable_preloads[key]
151
+ end
152
+
101
153
  def extract_includes_from_method(method_node, included_set)
102
154
  args = method_node.children[2..]
103
155
  args&.each do |arg|
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "1.0.2"
4
+ VERSION = "1.0.4"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eager_eye
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 1.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-12-19 00:00:00.000000000 Z
11
+ date: 2025-12-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ast