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 +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +38 -25
- data/lib/eager_eye/detectors/callback_query.rb +30 -4
- data/lib/eager_eye/detectors/loop_association.rb +61 -9
- data/lib/eager_eye/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7ebed3daa9046b0b6bbda1cbb7a9cce182af014ac31334973b4d98a5a1059bc
|
|
4
|
+
data.tar.gz: 61e220f6737089f52ab495d422cac8b91bf4720d6551c6a42abad7801c7d3c79
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml)
|
|
4
|
-
[](https://rubygems.org/gems/eager_eye)
|
|
5
5
|
[](https://github.com/hamzagedikkaya/eager_eye)
|
|
6
6
|
[](https://www.ruby-lang.org/)
|
|
7
7
|
[](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
|
|
222
|
+
Detects N+1 patterns inside ActiveRecord callbacks - specifically iterations that execute queries on each loop.
|
|
213
223
|
|
|
214
224
|
```ruby
|
|
215
|
-
# Bad -
|
|
216
|
-
class
|
|
217
|
-
|
|
225
|
+
# Bad - N+1 in callback (DETECTED)
|
|
226
|
+
class Order < ApplicationRecord
|
|
227
|
+
after_create :notify_subscribers
|
|
218
228
|
|
|
219
|
-
def
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
#
|
|
226
|
-
Article
|
|
227
|
-
|
|
236
|
+
# OK - Single query in callback (NOT flagged - not N+1)
|
|
237
|
+
class Article < ApplicationRecord
|
|
238
|
+
after_save :update_stats
|
|
228
239
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
240
|
+
def update_stats
|
|
241
|
+
author.articles.count # Single query, acceptable
|
|
242
|
+
end
|
|
243
|
+
end
|
|
232
244
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 -
|
|
241
|
-
|
|
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
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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|
|
data/lib/eager_eye/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2025-12-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ast
|