eager_eye 1.2.15 → 1.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +227 -541
- data/README.tr.md +489 -0
- data/lib/eager_eye/baseline.rb +46 -0
- data/lib/eager_eye/cli.rb +15 -1
- data/lib/eager_eye/issue.rb +18 -6
- data/lib/eager_eye/version.rb +1 -1
- data/lib/eager_eye.rb +1 -0
- metadata +4 -2
data/README.md
CHANGED
|
@@ -1,87 +1,52 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="images/icon.png" alt="EagerEye
|
|
2
|
+
<img src="images/icon.png" alt="EagerEye" width="140">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<h1 align="center">EagerEye</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<strong>
|
|
8
|
+
<strong>Catch N+1 queries in your Rails app — without running it.</strong><br>
|
|
9
|
+
<sub>Static analysis powered by Ruby AST. Fast. Zero runtime overhead. CI-ready.</sub>
|
|
9
10
|
</p>
|
|
10
11
|
|
|
11
12
|
<p align="center">
|
|
12
|
-
<a href="
|
|
13
|
-
<a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.2.15-red.svg" alt="Gem Version"></a>
|
|
14
|
-
<a href="https://github.com/hamzagedikkaya/eager_eye"><img src="https://img.shields.io/badge/coverage-95%25-brightgreen.svg" alt="Coverage"></a>
|
|
15
|
-
<a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg" alt="Ruby"></a>
|
|
16
|
-
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
17
|
-
<a href="https://marketplace.visualstudio.com/items?itemName=hamzagedikkaya.eager-eye"><img src="https://img.shields.io/badge/VS%20Code-Extension-blue.svg" alt="VS Code Extension"></a>
|
|
13
|
+
<strong>English</strong> · <a href="README.tr.md">Türkçe</a>
|
|
18
14
|
</p>
|
|
19
15
|
|
|
20
16
|
<p align="center">
|
|
21
|
-
<
|
|
17
|
+
<a href="https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml"><img src="https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml/badge.svg" alt="CI"></a>
|
|
18
|
+
<a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/gem/v/eager_eye?color=red&label=gem" alt="Gem Version"></a>
|
|
19
|
+
<a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/gem/dt/eager_eye?color=blue&label=downloads" alt="Downloads"></a>
|
|
20
|
+
<a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-CC342D" alt="Ruby"></a>
|
|
21
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/github/license/hamzagedikkaya/eager_eye" alt="License"></a>
|
|
22
|
+
<a href="https://marketplace.visualstudio.com/items?itemName=hamzagedikkaya.eager-eye"><img src="https://img.shields.io/badge/VS%20Code-Extension-007ACC?logo=visualstudiocode&logoColor=white" alt="VS Code Extension"></a>
|
|
22
23
|
</p>
|
|
23
24
|
|
|
25
|
+
> 💡 **Prefer in-editor warnings?** Install the [VS Code extension](https://marketplace.visualstudio.com/items?itemName=hamzagedikkaya.eager-eye) — same engine, runs on save, surfaces issues right next to the offending line. Same speed as the CLI, just a smoother feedback loop.
|
|
26
|
+
|
|
24
27
|
---
|
|
25
28
|
|
|
26
|
-
##
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
- [CLI Reference](#cli-reference)
|
|
37
|
-
- [Output Formats](#output-formats)
|
|
38
|
-
- [Limitations](#limitations)
|
|
39
|
-
- [VS Code Extension](#vs-code-extension)
|
|
40
|
-
- [Development](#development)
|
|
41
|
-
- [Contributing](#contributing)
|
|
42
|
-
|
|
43
|
-
## Features
|
|
44
|
-
|
|
45
|
-
✨ **Detects 11 types of N+1 problems:**
|
|
46
|
-
- Loop associations (queries in iterations)
|
|
47
|
-
- Serializer nesting issues
|
|
48
|
-
- Missing counter caches
|
|
49
|
-
- Custom method queries
|
|
50
|
-
- Count in iteration patterns
|
|
51
|
-
- Callback query N+1s
|
|
52
|
-
- Pluck to array misuse
|
|
53
|
-
- Delegation N+1s (hidden via `delegate :method, to: :association`)
|
|
54
|
-
- Decorator N+1s (Draper, SimpleDelegator, Presenter, ViewObject)
|
|
55
|
-
- Scope chain N+1s (named scopes on associations in loops)
|
|
56
|
-
- Validation N+1s (uniqueness validation in batch create/save)
|
|
57
|
-
|
|
58
|
-
🔧 **Developer-friendly:**
|
|
59
|
-
- Inline suppression (like RuboCop)
|
|
60
|
-
- Auto-fix support (3 fixers: PluckToSelect, CountToSize, AddIncludes)
|
|
61
|
-
- `.jbuilder` file support (`json.array!` iteration detection)
|
|
62
|
-
- JSON/Console output formats
|
|
63
|
-
- RSpec integration
|
|
64
|
-
|
|
65
|
-
🚀 **CI-ready:**
|
|
66
|
-
- No test suite required
|
|
67
|
-
- GitHub Actions examples included
|
|
68
|
-
- Severity levels and filtering
|
|
69
|
-
|
|
70
|
-
## Installation
|
|
71
|
-
|
|
72
|
-
Add to your Gemfile:
|
|
29
|
+
## Why EagerEye?
|
|
30
|
+
|
|
31
|
+
**Bullet** finds N+1s when your tests hit them. **EagerEye** finds them statically — before any code runs.
|
|
32
|
+
|
|
33
|
+
- 🎯 **Catch what tests miss** — N+1s in code paths your test suite doesn't exercise still get flagged.
|
|
34
|
+
- ⚡ **Run in CI on every PR** — no DB, no fixtures, no Rails boot. Just `eager_eye app/`.
|
|
35
|
+
- 🔬 **11 detector types** — beyond simple loop access: serializer nesting, callback queries, decorator/delegation traps, batch validation, scope chains, plucked-array misuse, and more.
|
|
36
|
+
- 🤝 **Plays well with Bullet** — static + runtime cover different blind spots. Use both.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
73
39
|
|
|
74
40
|
```ruby
|
|
41
|
+
# Gemfile
|
|
75
42
|
gem "eager_eye", group: :development
|
|
76
43
|
```
|
|
77
44
|
|
|
78
|
-
Then run:
|
|
79
|
-
|
|
80
45
|
```bash
|
|
81
46
|
bundle install
|
|
82
47
|
```
|
|
83
48
|
|
|
84
|
-
Or
|
|
49
|
+
Or standalone:
|
|
85
50
|
|
|
86
51
|
```bash
|
|
87
52
|
gem install eager_eye
|
|
@@ -89,477 +54,322 @@ gem install eager_eye
|
|
|
89
54
|
|
|
90
55
|
## Quick Start
|
|
91
56
|
|
|
92
|
-
### CLI Usage
|
|
93
|
-
|
|
94
57
|
```bash
|
|
95
|
-
#
|
|
58
|
+
# Scan the default app/ directory
|
|
96
59
|
eager_eye
|
|
97
60
|
|
|
98
|
-
#
|
|
61
|
+
# Or scan specific paths
|
|
99
62
|
eager_eye app/controllers app/serializers
|
|
100
63
|
|
|
101
|
-
#
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
# Don't fail on issues (exit 0)
|
|
105
|
-
eager_eye --no-fail
|
|
106
|
-
|
|
107
|
-
# Run specific detectors only
|
|
108
|
-
eager_eye --only loop_association,serializer_nesting
|
|
64
|
+
# Generate a config file (optional)
|
|
65
|
+
rails g eager_eye:install
|
|
109
66
|
|
|
110
|
-
#
|
|
111
|
-
eager_eye
|
|
67
|
+
# Run via rake
|
|
68
|
+
rake eager_eye:analyze
|
|
112
69
|
```
|
|
113
70
|
|
|
114
|
-
|
|
71
|
+
Sample output:
|
|
115
72
|
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
|
|
73
|
+
```text
|
|
74
|
+
app/controllers/posts_controller.rb
|
|
75
|
+
Line 15: [LoopAssociation] Potential N+1 query: `post.author` called inside iteration
|
|
76
|
+
Suggestion: Use `includes(:author)` on the collection before iterating
|
|
119
77
|
|
|
120
|
-
|
|
121
|
-
|
|
78
|
+
Line 23: [MissingCounterCache] `.count` called on `comments` may cause N+1 queries
|
|
79
|
+
Suggestion: Add `counter_cache: true` to the belongs_to association
|
|
122
80
|
|
|
123
|
-
|
|
124
|
-
rake eager_eye:json
|
|
81
|
+
Total: 2 issues (2 warnings, 0 errors)
|
|
125
82
|
```
|
|
126
83
|
|
|
127
|
-
##
|
|
84
|
+
## What it detects
|
|
85
|
+
|
|
86
|
+
| # | Detector | What it catches |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| 1 | **LoopAssociation** | Association calls inside `each`/`map`/`find_each`/etc. without preloading |
|
|
89
|
+
| 2 | **SerializerNesting** | Nested association access in Blueprinter / ActiveModel::Serializer / Alba blocks |
|
|
90
|
+
| 3 | **MissingCounterCache** | `.count` / `.size` on associations inside loops where a counter cache would help |
|
|
91
|
+
| 4 | **CustomMethodQuery** | `.where`, `.find_by`, `.exists?` etc. on association chains inside iterations |
|
|
92
|
+
| 5 | **CountInIteration** | `.count` (always queries) used in loops where `.size` (uses preload) would suffice |
|
|
93
|
+
| 6 | **CallbackQuery** | Iteration-driven queries inside ActiveRecord callbacks (`after_save`, `after_create`, ...) |
|
|
94
|
+
| 7 | **PluckToArray** | `.pluck(:id)` results passed to `where(id: ...)` instead of using a subquery; flags `.all.pluck` as critical |
|
|
95
|
+
| 8 | **DelegationNPlusOne** | `delegate :method, to: :association` calls in loops where the target isn't preloaded |
|
|
96
|
+
| 9 | **DecoratorNPlusOne** | Draper / SimpleDelegator / Presenter / ViewObject access without preload before `.decorate` |
|
|
97
|
+
| 10 | **ScopeChainNPlusOne** | Named scopes (`.recent`, `.active`) on associations in loops — invisible query triggers |
|
|
98
|
+
| 11 | **ValidationNPlusOne** | `Model.create`/`save` inside loops on models with `validates :x, uniqueness: true` |
|
|
128
99
|
|
|
129
|
-
|
|
100
|
+
EagerEye also tracks preloads across pagination wrappers (`pagy`, `paginate`, `kaminari`), per-method scope, multi-line builder chains, and helper-method parameters — so warnings respect the eager-loading you've already set up.
|
|
130
101
|
|
|
131
|
-
|
|
102
|
+
<details>
|
|
103
|
+
<summary><b>Detailed examples for each detector →</b></summary>
|
|
104
|
+
|
|
105
|
+
### 1. LoopAssociation
|
|
132
106
|
|
|
133
107
|
```ruby
|
|
134
|
-
# Bad
|
|
135
|
-
posts.each
|
|
136
|
-
post.author.name # Query for each post!
|
|
137
|
-
post.comments.count # Another query for each post!
|
|
138
|
-
end
|
|
108
|
+
# Bad
|
|
109
|
+
posts.each { |post| post.author.name } # query per post
|
|
139
110
|
|
|
140
|
-
# Good
|
|
141
|
-
posts.includes(:author
|
|
142
|
-
post.author.name # No additional query
|
|
143
|
-
post.comments.count # No additional query
|
|
144
|
-
end
|
|
111
|
+
# Good — chained
|
|
112
|
+
posts.includes(:author).each { |post| post.author.name }
|
|
145
113
|
|
|
146
|
-
# Good
|
|
114
|
+
# Good — separate line (preload tracked across assignment)
|
|
147
115
|
@posts = Post.includes(:author)
|
|
148
|
-
@posts.each
|
|
149
|
-
post.author.name # No warning - EagerEye tracks the preload
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# Also works with preload and eager_load
|
|
153
|
-
posts = Post.preload(:comments)
|
|
154
|
-
posts.each { |post| post.comments.size } # No warning
|
|
116
|
+
@posts.each { |post| post.author.name }
|
|
155
117
|
|
|
156
|
-
#
|
|
118
|
+
# Good — single record (no N+1 possible)
|
|
157
119
|
@user = User.find(params[:id])
|
|
158
|
-
@user.posts.each
|
|
159
|
-
post.comments # No warning - single user, no N+1
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
# Scope-defined preloads are recognized (v1.1.0+)
|
|
163
|
-
# In Post model:
|
|
164
|
-
class Post < ApplicationRecord
|
|
165
|
-
has_many :comments, -> { includes(:author) }
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
# In controller - EagerEye recognizes comments are already preloaded via scope!
|
|
169
|
-
posts.each do |post|
|
|
170
|
-
post.comments.map(&:author) # No warning - preloaded via scope
|
|
171
|
-
end
|
|
120
|
+
@user.posts.each { |post| post.comments }
|
|
172
121
|
```
|
|
173
122
|
|
|
174
|
-
|
|
123
|
+
Recognizes `.includes`, `.preload`, `.eager_load`, scoped `has_many` (`-> { includes(:author) }`), and pagination wrappers like `@pagy, items = pagy(...)`.
|
|
175
124
|
|
|
176
|
-
|
|
125
|
+
### 2. SerializerNesting
|
|
177
126
|
|
|
178
127
|
```ruby
|
|
179
|
-
# Bad
|
|
180
|
-
class PostSerializer <
|
|
181
|
-
|
|
182
|
-
object.author.name # Query for each serialized post!
|
|
183
|
-
end
|
|
128
|
+
# Bad
|
|
129
|
+
class PostSerializer < Blueprinter::Base
|
|
130
|
+
field :author_name { |post| post.author.name } # query per serialized post
|
|
184
131
|
end
|
|
185
132
|
|
|
186
|
-
# Good
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
@posts = Post.includes(:author)
|
|
190
|
-
render json: @posts, each_serializer: PostSerializer
|
|
191
|
-
end
|
|
192
|
-
end
|
|
133
|
+
# Good — preload in controller
|
|
134
|
+
@posts = Post.includes(:author)
|
|
135
|
+
render json: PostSerializer.render(@posts)
|
|
193
136
|
```
|
|
194
137
|
|
|
195
|
-
Supports
|
|
196
|
-
- ActiveModel::Serializers
|
|
197
|
-
- Blueprinter
|
|
198
|
-
- Alba
|
|
138
|
+
Supports Blueprinter, ActiveModel::Serializers, Alba.
|
|
199
139
|
|
|
200
|
-
### 3.
|
|
201
|
-
|
|
202
|
-
Detects `.count`, `.size`, or `.length` calls on associations **inside iterations** that could benefit from counter caches. Single calls outside loops are not flagged since they don't cause N+1 issues.
|
|
140
|
+
### 3. MissingCounterCache
|
|
203
141
|
|
|
204
142
|
```ruby
|
|
205
|
-
# Bad
|
|
206
|
-
posts.each
|
|
207
|
-
post.comments.count # Detected: N+1 query!
|
|
208
|
-
post.likes.size # Detected: N+1 query!
|
|
209
|
-
end
|
|
143
|
+
# Bad — COUNT query for each post
|
|
144
|
+
posts.each { |post| post.comments.count }
|
|
210
145
|
|
|
211
|
-
#
|
|
212
|
-
post.
|
|
213
|
-
|
|
214
|
-
# Good - Add counter cache for iteration use cases
|
|
215
|
-
# In Comment model:
|
|
216
|
-
belongs_to :post, counter_cache: true
|
|
217
|
-
|
|
218
|
-
# Then this is a simple column read:
|
|
219
|
-
posts.each do |post|
|
|
220
|
-
post.comments_count # No query - just reads the column
|
|
221
|
-
end
|
|
146
|
+
# Good — counter cache (Comment: belongs_to :post, counter_cache: true)
|
|
147
|
+
posts.each { |post| post.comments_count } # column read, no query
|
|
222
148
|
```
|
|
223
149
|
|
|
224
|
-
|
|
150
|
+
Only flagged inside iterations — single calls don't cause N+1.
|
|
225
151
|
|
|
226
|
-
|
|
152
|
+
### 4. CustomMethodQuery
|
|
227
153
|
|
|
228
154
|
```ruby
|
|
229
|
-
# Bad
|
|
230
|
-
|
|
231
|
-
def supports?(team_name)
|
|
232
|
-
teams.where(name: team_name).exists?
|
|
233
|
-
end
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
@users.each do |user|
|
|
237
|
-
user.supports?("Lakers") # Query for each user!
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
# Bad - find_by inside loop
|
|
241
|
-
@orders.each do |order|
|
|
242
|
-
order.line_items.find_by(featured: true)
|
|
243
|
-
end
|
|
155
|
+
# Bad — where inside loop
|
|
156
|
+
@users.each { |user| user.teams.where(name: "Lakers").exists? }
|
|
244
157
|
|
|
245
|
-
# Good
|
|
246
|
-
@users.includes(:teams).each
|
|
247
|
-
user.teams.any? { |t| t.name == "Lakers" }
|
|
248
|
-
end
|
|
158
|
+
# Good — preload + filter in Ruby
|
|
159
|
+
@users.includes(:teams).each { |user| user.teams.any? { |t| t.name == "Lakers" } }
|
|
249
160
|
```
|
|
250
161
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
### 5. Count in Iteration
|
|
162
|
+
Detected: `where`, `find_by`, `exists?`, `find`, `first`, `last`, `take`, `pluck`, `count`, `sum`, `average`, `minimum`, `maximum`. Per-model scoped — won't flag `obj.foo` just because some other model defines `def foo` with a query.
|
|
254
163
|
|
|
255
|
-
|
|
164
|
+
### 5. CountInIteration
|
|
256
165
|
|
|
257
166
|
```ruby
|
|
258
|
-
# Bad
|
|
167
|
+
# Bad — .count always queries, even with includes
|
|
259
168
|
@users = User.includes(:posts)
|
|
260
|
-
@users.each
|
|
261
|
-
user.posts.count # Executes: SELECT COUNT(*) FROM posts WHERE user_id = ?
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
# Good - Use .size (checks if loaded first)
|
|
265
|
-
@users.each do |user|
|
|
266
|
-
user.posts.size # No query - counts the loaded array
|
|
267
|
-
end
|
|
169
|
+
@users.each { |user| user.posts.count } # SELECT COUNT(*) per user
|
|
268
170
|
|
|
269
|
-
#
|
|
270
|
-
|
|
271
|
-
user.posts_count # Just reads the column
|
|
171
|
+
# Good — .size uses the preload
|
|
172
|
+
@users.each { |user| user.posts.size }
|
|
272
173
|
```
|
|
273
174
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
| Method | Loaded Collection | Not Loaded |
|
|
277
|
-
|--------|------------------|------------|
|
|
175
|
+
| Method | Loaded | Not loaded |
|
|
176
|
+
|---|---|---|
|
|
278
177
|
| `.count` | COUNT query | COUNT query |
|
|
279
|
-
| `.size` |
|
|
280
|
-
| `.length` |
|
|
281
|
-
|
|
282
|
-
### 6. Callback Query Detection
|
|
178
|
+
| `.size` | array#size | COUNT query |
|
|
179
|
+
| `.length` | array#length | loads all then counts |
|
|
283
180
|
|
|
284
|
-
|
|
181
|
+
### 6. CallbackQuery
|
|
285
182
|
|
|
286
183
|
```ruby
|
|
287
|
-
# Bad
|
|
184
|
+
# Bad — N+1 inside callback
|
|
288
185
|
class Order < ApplicationRecord
|
|
289
186
|
after_create :notify_subscribers
|
|
290
187
|
|
|
291
188
|
def notify_subscribers
|
|
292
|
-
customer.followers.each
|
|
293
|
-
follower.notifications.create!(...) # Warning: Query on iteration variable
|
|
294
|
-
end
|
|
189
|
+
customer.followers.each { |f| f.notifications.create!(...) } # N inserts + N queries
|
|
295
190
|
end
|
|
296
191
|
end
|
|
297
192
|
|
|
298
|
-
#
|
|
299
|
-
class Article < ApplicationRecord
|
|
300
|
-
after_save :update_stats
|
|
301
|
-
|
|
302
|
-
def update_stats
|
|
303
|
-
author.articles.count # Single query, acceptable
|
|
304
|
-
end
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
# OK - Query not on iteration variable (NOT flagged)
|
|
308
|
-
class Post < ApplicationRecord
|
|
309
|
-
after_save :process_items
|
|
310
|
-
|
|
311
|
-
def process_items
|
|
312
|
-
items.each do |item|
|
|
313
|
-
OtherModel.where(name: item.name).first # OtherModel is receiver, not item
|
|
314
|
-
end
|
|
315
|
-
end
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
# Good - Move iterations to background job
|
|
193
|
+
# Good — defer to background job
|
|
319
194
|
after_commit :schedule_notifications, on: :create
|
|
320
|
-
|
|
321
195
|
def schedule_notifications
|
|
322
196
|
NotifySubscribersJob.perform_later(id)
|
|
323
197
|
end
|
|
324
198
|
```
|
|
325
199
|
|
|
326
|
-
### 7.
|
|
327
|
-
|
|
328
|
-
Detects when `.pluck(:id)` or `.map(&:id)` results are used in `where` clauses instead of subqueries.
|
|
200
|
+
### 7. PluckToArray
|
|
329
201
|
|
|
330
202
|
```ruby
|
|
331
|
-
#
|
|
203
|
+
# Warning — two queries + memory overhead
|
|
332
204
|
user_ids = User.active.pluck(:id)
|
|
333
|
-
Post.where(user_id: user_ids)
|
|
205
|
+
Post.where(user_id: user_ids)
|
|
334
206
|
|
|
335
|
-
#
|
|
207
|
+
# Error — loads entire table
|
|
336
208
|
user_ids = User.all.pluck(:id)
|
|
337
209
|
Post.where(user_id: user_ids)
|
|
338
210
|
|
|
339
|
-
# Good
|
|
211
|
+
# Good — single subquery
|
|
340
212
|
Post.where(user_id: User.active.select(:id))
|
|
341
213
|
```
|
|
342
214
|
|
|
343
|
-
|
|
344
|
-
- ⚠️ **Warning** - Scoped `.pluck(:id)` (two queries, memory overhead)
|
|
345
|
-
- 🔴 **Error** - Unscoped `.all.pluck(:id)` (loads entire table)
|
|
215
|
+
`.where(...).all.pluck(:id)` is correctly recognized as scoped, not a table scan.
|
|
346
216
|
|
|
347
|
-
### 8.
|
|
348
|
-
|
|
349
|
-
Detects when methods delegated via `delegate :method, to: :association` are called inside loops without preloading the target association. These are invisible to `LoopAssociation` because `order.name` looks like a plain attribute, not an association access.
|
|
217
|
+
### 8. DelegationNPlusOne
|
|
350
218
|
|
|
351
219
|
```ruby
|
|
352
|
-
# Model
|
|
353
220
|
class Order < ApplicationRecord
|
|
354
221
|
belongs_to :user
|
|
355
222
|
delegate :full_name, :email, to: :user
|
|
356
223
|
end
|
|
357
224
|
|
|
358
|
-
# Bad
|
|
359
|
-
orders.each
|
|
360
|
-
order.full_name # actually: order.user.full_name — loads user for each order!
|
|
361
|
-
order.email # actually: order.user.email — another load!
|
|
362
|
-
end
|
|
225
|
+
# Bad — looks like attribute access, actually loads user per order
|
|
226
|
+
orders.each { |o| o.full_name }
|
|
363
227
|
|
|
364
|
-
# Good
|
|
365
|
-
orders.includes(:user).each
|
|
366
|
-
order.full_name # no N+1 — user is already loaded
|
|
367
|
-
order.email # no N+1 — user is already loaded
|
|
368
|
-
end
|
|
228
|
+
# Good
|
|
229
|
+
orders.includes(:user).each { |o| o.full_name }
|
|
369
230
|
```
|
|
370
231
|
|
|
371
|
-
|
|
372
|
-
1. Scanning model files for `delegate :method, to: :assoc` declarations
|
|
373
|
-
2. Tracking which methods delegate to which associations
|
|
374
|
-
3. Flagging calls to those methods inside iteration blocks when the association is not preloaded
|
|
232
|
+
Cross-file: scans models for `delegate ... to: :assoc` declarations.
|
|
375
233
|
|
|
376
|
-
### 9.
|
|
377
|
-
|
|
378
|
-
Detects N+1 queries inside Draper decorators, SimpleDelegator subclasses, and classes named `Decorator`, `Presenter`, or `ViewObject`. Each decorator wraps a single record — when a collection is decorated without preloading, every method that accesses an association triggers a new query per record.
|
|
234
|
+
### 9. DecoratorNPlusOne
|
|
379
235
|
|
|
380
236
|
```ruby
|
|
381
|
-
# Bad - N+1 on each decorated post
|
|
382
237
|
class PostDecorator < Draper::Decorator
|
|
383
238
|
def comment_summary
|
|
384
|
-
object.comments.map(&:body).join(", ") #
|
|
385
|
-
end
|
|
386
|
-
|
|
387
|
-
def tag_list
|
|
388
|
-
object.tags.map(&:name).join(", ") # Another query for each post!
|
|
239
|
+
object.comments.map(&:body).join(", ") # query per decorated post
|
|
389
240
|
end
|
|
390
241
|
end
|
|
391
242
|
|
|
392
|
-
#
|
|
243
|
+
# Bad
|
|
393
244
|
@posts = Post.all.decorate
|
|
394
245
|
|
|
395
|
-
# Good
|
|
396
|
-
@posts = Post.includes(:comments
|
|
246
|
+
# Good
|
|
247
|
+
@posts = Post.includes(:comments).all.decorate
|
|
397
248
|
```
|
|
398
249
|
|
|
399
|
-
|
|
400
|
-
- `object` — Draper standard
|
|
401
|
-
- `__getobj__` — SimpleDelegator standard
|
|
402
|
-
- `source`, `model` — alternative Draper aliases
|
|
403
|
-
|
|
404
|
-
### 10. Scope Chain N+1
|
|
250
|
+
Recognizes `object`, `__getobj__`, `source`, `model` references inside Draper / SimpleDelegator / Presenter / ViewObject classes.
|
|
405
251
|
|
|
406
|
-
|
|
252
|
+
### 10. ScopeChainNPlusOne
|
|
407
253
|
|
|
408
254
|
```ruby
|
|
409
|
-
# Model
|
|
410
255
|
class Comment < ApplicationRecord
|
|
411
256
|
scope :recent, -> { where("created_at > ?", 1.week.ago) }
|
|
412
|
-
scope :approved, -> { where(approved: true) }
|
|
413
257
|
end
|
|
414
258
|
|
|
415
|
-
# Bad
|
|
416
|
-
posts.each
|
|
417
|
-
post.comments.recent # Query for each post!
|
|
418
|
-
post.comments.approved.count # Query for each post!
|
|
419
|
-
end
|
|
259
|
+
# Bad — scope call per iteration
|
|
260
|
+
posts.each { |post| post.comments.recent }
|
|
420
261
|
|
|
421
|
-
# Good
|
|
422
|
-
posts.includes(:comments).each
|
|
423
|
-
post.comments.select { |c| c.created_at > 1.week.ago }
|
|
424
|
-
end
|
|
262
|
+
# Good — preload + filter
|
|
263
|
+
posts.includes(:comments).each { |post| post.comments.select { |c| c.created_at > 1.week.ago } }
|
|
425
264
|
```
|
|
426
265
|
|
|
427
|
-
|
|
428
|
-
1. Scanning model files for `scope :name, -> { ... }` declarations
|
|
429
|
-
2. Flagging known scope names called on association chains inside iteration blocks
|
|
266
|
+
Cross-file: scans models for `scope :name, -> { ... }` declarations.
|
|
430
267
|
|
|
431
|
-
### 11.
|
|
432
|
-
|
|
433
|
-
Detects batch create/save operations inside iterations for models with `validates uniqueness`. Each uniqueness validation triggers a SELECT query per record, resulting in 2N queries (SELECT + INSERT per record).
|
|
268
|
+
### 11. ValidationNPlusOne
|
|
434
269
|
|
|
435
270
|
```ruby
|
|
436
|
-
# Model
|
|
437
271
|
class User < ApplicationRecord
|
|
438
272
|
validates :email, uniqueness: true
|
|
439
273
|
end
|
|
440
274
|
|
|
441
|
-
# Bad
|
|
442
|
-
params[:users].each
|
|
443
|
-
User.create!(user_params) # SELECT + INSERT for each!
|
|
444
|
-
end
|
|
275
|
+
# Bad — SELECT + INSERT per record
|
|
276
|
+
params[:users].each { |p| User.create!(p) }
|
|
445
277
|
|
|
446
|
-
#
|
|
447
|
-
params[:users]
|
|
448
|
-
user = User.new(user_params)
|
|
449
|
-
user.save! # SELECT + INSERT for each!
|
|
450
|
-
end
|
|
451
|
-
|
|
452
|
-
# Good - use insert_all with unique index
|
|
453
|
-
User.insert_all(params[:users]) # Single bulk INSERT, DB enforces uniqueness
|
|
278
|
+
# Good — single bulk INSERT, DB enforces uniqueness via index
|
|
279
|
+
User.insert_all(params[:users])
|
|
454
280
|
```
|
|
455
281
|
|
|
456
|
-
|
|
457
|
-
1. Scanning model files for `validates :attr, uniqueness: true` or `validates_uniqueness_of` declarations
|
|
458
|
-
2. Flagging `Model.create/create!` or `Model.new` + `.save/.save!` patterns inside iteration blocks
|
|
282
|
+
</details>
|
|
459
283
|
|
|
460
|
-
## Inline
|
|
284
|
+
## Inline suppression
|
|
461
285
|
|
|
462
|
-
|
|
286
|
+
RuboCop-style comments suppress false positives or accepted patterns:
|
|
463
287
|
|
|
464
288
|
```ruby
|
|
465
|
-
#
|
|
289
|
+
# Single line
|
|
466
290
|
user.posts.count # eager_eye:disable CountInIteration
|
|
467
291
|
|
|
468
|
-
#
|
|
292
|
+
# Next line
|
|
469
293
|
# eager_eye:disable-next-line LoopAssociation
|
|
470
294
|
@users.each { |u| u.profile }
|
|
471
295
|
|
|
472
|
-
#
|
|
296
|
+
# Block
|
|
473
297
|
# eager_eye:disable LoopAssociation, SerializerNesting
|
|
474
|
-
@users.each
|
|
475
|
-
user.posts.each { |p| p.author }
|
|
476
|
-
end
|
|
298
|
+
@users.each { |u| u.posts.each { |p| p.author } }
|
|
477
299
|
# eager_eye:enable LoopAssociation, SerializerNesting
|
|
478
300
|
|
|
479
|
-
#
|
|
301
|
+
# Whole file (must be in first 5 lines)
|
|
480
302
|
# eager_eye:disable-file CustomMethodQuery
|
|
481
303
|
|
|
482
304
|
# With reason
|
|
483
305
|
user.posts.count # eager_eye:disable CountInIteration -- using counter_cache
|
|
484
306
|
|
|
485
|
-
# Disable
|
|
307
|
+
# Disable everything
|
|
486
308
|
# eager_eye:disable all
|
|
487
309
|
```
|
|
488
310
|
|
|
489
|
-
|
|
311
|
+
Detector names are accepted as either CamelCase (`LoopAssociation`) or snake_case (`loop_association`).
|
|
490
312
|
|
|
491
|
-
|
|
313
|
+
## Auto-fix (experimental)
|
|
492
314
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
| Custom Method Query | `CustomMethodQuery` | `custom_method_query` |
|
|
499
|
-
| Count in Iteration | `CountInIteration` | `count_in_iteration` |
|
|
500
|
-
| Callback Query | `CallbackQuery` | `callback_query` |
|
|
501
|
-
| Pluck to Array | `PluckToArray` | `pluck_to_array` |
|
|
502
|
-
| Delegation N+1 | `DelegationNPlusOne` | `delegation_n_plus_one` |
|
|
503
|
-
| Decorator N+1 | `DecoratorNPlusOne` | `decorator_n_plus_one` |
|
|
504
|
-
| Scope Chain N+1 | `ScopeChainNPlusOne` | `scope_chain_n_plus_one` |
|
|
505
|
-
| Validation N+1 | `ValidationNPlusOne` | `validation_n_plus_one` |
|
|
506
|
-
| All Detectors | `all` | `all` |
|
|
315
|
+
```bash
|
|
316
|
+
eager_eye --suggest-fixes # show diff
|
|
317
|
+
eager_eye --fix # interactive
|
|
318
|
+
eager_eye --fix --force # apply all without confirmation
|
|
319
|
+
```
|
|
507
320
|
|
|
508
|
-
|
|
321
|
+
| Issue | Auto-fix |
|
|
322
|
+
|---|---|
|
|
323
|
+
| `.pluck(:id)` used in `.where(id: ...)` | → `.select(:id)` |
|
|
324
|
+
| `.count` in iteration | → `.size` |
|
|
325
|
+
| Missing `includes` before loop | → inserts `.includes(:assoc)` |
|
|
509
326
|
|
|
510
|
-
|
|
327
|
+
> ⚠ Always review the diff and re-run your test suite after `--fix`.
|
|
511
328
|
|
|
512
|
-
|
|
513
|
-
# Show fix suggestions
|
|
514
|
-
eager_eye --suggest-fixes
|
|
329
|
+
## CI integration
|
|
515
330
|
|
|
516
|
-
|
|
517
|
-
eager_eye
|
|
331
|
+
```yaml
|
|
332
|
+
# .github/workflows/eager_eye.yml
|
|
333
|
+
name: EagerEye
|
|
334
|
+
on: [pull_request]
|
|
518
335
|
|
|
519
|
-
|
|
520
|
-
|
|
336
|
+
jobs:
|
|
337
|
+
analyze:
|
|
338
|
+
runs-on: ubuntu-latest
|
|
339
|
+
steps:
|
|
340
|
+
- uses: actions/checkout@v4
|
|
341
|
+
- uses: ruby/setup-ruby@v1
|
|
342
|
+
with:
|
|
343
|
+
ruby-version: "3.3"
|
|
344
|
+
- run: gem install eager_eye
|
|
345
|
+
- run: eager_eye app/ --format json > report.json
|
|
346
|
+
- run: |
|
|
347
|
+
issues=$(ruby -rjson -e 'puts JSON.parse(File.read("report.json"))["summary"]["total_issues"]')
|
|
348
|
+
[ "$issues" -gt 0 ] && echo "::warning::Found $issues potential N+1 issues" || true
|
|
521
349
|
```
|
|
522
350
|
|
|
523
|
-
|
|
351
|
+
See [examples/github_action.yml](examples/github_action.yml) for a fuller setup with PR annotations.
|
|
524
352
|
|
|
525
|
-
|
|
526
|
-
|-------|-----|
|
|
527
|
-
| `.pluck(:id)` inline | → `.select(:id)` |
|
|
528
|
-
| `.count` in iteration | → `.size` |
|
|
529
|
-
| Missing `includes` before loop | → `.includes(:assoc)` inserted |
|
|
353
|
+
### Baseline mode (brownfield projects)
|
|
530
354
|
|
|
531
|
-
|
|
355
|
+
Most existing Rails apps have hundreds of N+1 issues already — failing CI on
|
|
356
|
+
every one of them is noise. Capture today's report as a baseline and let CI
|
|
357
|
+
fail **only on regressions** (new issues introduced by a PR):
|
|
532
358
|
|
|
533
|
-
```
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
app/services/user_service.rb:
|
|
537
|
-
Line 12:
|
|
538
|
-
- Post.where(user_id: User.active.pluck(:id))
|
|
539
|
-
+ Post.where(user_id: User.active.select(:id))
|
|
540
|
-
|
|
541
|
-
app/controllers/posts_controller.rb:
|
|
542
|
-
Line 8:
|
|
543
|
-
- user.posts.count
|
|
544
|
-
+ user.posts.size
|
|
545
|
-
|
|
546
|
-
Line 5:
|
|
547
|
-
- @posts.each do |post|
|
|
548
|
-
+ @posts.includes(:author).each do |post|
|
|
549
|
-
|
|
550
|
-
$ eager_eye --fix
|
|
551
|
-
app/services/user_service.rb:12
|
|
552
|
-
- Post.where(user_id: User.active.pluck(:id))
|
|
553
|
-
+ Post.where(user_id: User.active.select(:id))
|
|
554
|
-
Apply this fix? [y/n/q] y
|
|
555
|
-
Applied
|
|
556
|
-
```
|
|
359
|
+
```bash
|
|
360
|
+
# One-time: capture the current state as the baseline
|
|
361
|
+
eager_eye app/ --format json > .eager_eye_baseline.json
|
|
557
362
|
|
|
558
|
-
|
|
363
|
+
# In CI: only NEW issues count
|
|
364
|
+
eager_eye app/ --baseline .eager_eye_baseline.json
|
|
365
|
+
```
|
|
559
366
|
|
|
560
|
-
|
|
367
|
+
The baseline file is a normal `--format json` report. Refresh it as you
|
|
368
|
+
fix existing issues. The match key is `(detector, file_path, line_number,
|
|
369
|
+
message, severity, suggestion)` — if any of those change for a known issue,
|
|
370
|
+
it shows up as "new" until the baseline is refreshed.
|
|
561
371
|
|
|
562
|
-
|
|
372
|
+
## RSpec integration
|
|
563
373
|
|
|
564
374
|
```ruby
|
|
565
375
|
# spec/rails_helper.rb
|
|
@@ -575,228 +385,104 @@ RSpec.describe "EagerEye Analysis" do
|
|
|
575
385
|
expect("app/serializers").to pass_eager_eye(only: [:serializer_nesting])
|
|
576
386
|
end
|
|
577
387
|
|
|
578
|
-
# Allow some
|
|
388
|
+
# Allow some during migration
|
|
579
389
|
it "legacy code is acceptable" do
|
|
580
390
|
expect("app/services/legacy").to pass_eager_eye(max_issues: 10)
|
|
581
391
|
end
|
|
582
|
-
|
|
583
|
-
it "models have no callback issues except legacy" do
|
|
584
|
-
expect("app/models").to pass_eager_eye(
|
|
585
|
-
only: [:callback_query],
|
|
586
|
-
exclude: ["app/models/legacy/**"]
|
|
587
|
-
)
|
|
588
|
-
end
|
|
589
392
|
end
|
|
590
393
|
```
|
|
591
394
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
| Option | Type | Description |
|
|
595
|
-
|--------|------|-------------|
|
|
596
|
-
| `only` | `Array<Symbol>` | Run only specified detectors |
|
|
597
|
-
| `exclude` | `Array<String>` | Glob patterns to exclude |
|
|
598
|
-
| `max_issues` | `Integer` | Maximum allowed issues (default: 0) |
|
|
395
|
+
Matcher options: `only:` (Array<Symbol>), `exclude:` (Array<String> globs), `max_issues:` (Integer, default 0).
|
|
599
396
|
|
|
600
397
|
## Configuration
|
|
601
398
|
|
|
602
|
-
### Config File (.eager_eye.yml)
|
|
603
|
-
|
|
604
399
|
```yaml
|
|
605
|
-
#
|
|
400
|
+
# .eager_eye.yml
|
|
606
401
|
excluded_paths:
|
|
607
|
-
- app/
|
|
402
|
+
- app/legacy/**
|
|
608
403
|
- lib/tasks/**
|
|
609
404
|
|
|
610
|
-
#
|
|
611
|
-
enabled_detectors:
|
|
405
|
+
enabled_detectors: # default: all
|
|
612
406
|
- loop_association
|
|
613
407
|
- serializer_nesting
|
|
614
|
-
- missing_counter_cache
|
|
615
408
|
- custom_method_query
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
- pluck_to_array
|
|
619
|
-
- delegation_n_plus_one
|
|
620
|
-
- decorator_n_plus_one
|
|
621
|
-
- scope_chain_n_plus_one
|
|
622
|
-
- validation_n_plus_one
|
|
623
|
-
|
|
624
|
-
# Severity levels per detector (error, warning, info)
|
|
409
|
+
# ...
|
|
410
|
+
|
|
625
411
|
severity_levels:
|
|
626
|
-
loop_association: error
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
count_in_iteration: warning
|
|
630
|
-
callback_query: warning
|
|
631
|
-
pluck_to_array: warning # Optimization
|
|
632
|
-
delegation_n_plus_one: warning # Hidden delegation N+1
|
|
633
|
-
decorator_n_plus_one: warning # Decorator/Presenter N+1
|
|
634
|
-
scope_chain_n_plus_one: warning # Scope chain on association
|
|
635
|
-
validation_n_plus_one: warning # Uniqueness validation in batch
|
|
636
|
-
missing_counter_cache: info # Suggestion
|
|
637
|
-
|
|
638
|
-
# Minimum severity to report (default: info)
|
|
639
|
-
min_severity: warning
|
|
640
|
-
|
|
641
|
-
# Base path to analyze (default: app)
|
|
642
|
-
app_path: app
|
|
412
|
+
loop_association: error
|
|
413
|
+
missing_counter_cache: info
|
|
414
|
+
# ...
|
|
643
415
|
|
|
644
|
-
#
|
|
416
|
+
min_severity: warning # info | warning | error
|
|
417
|
+
app_path: app
|
|
645
418
|
fail_on_issues: true
|
|
646
419
|
```
|
|
647
420
|
|
|
648
|
-
|
|
421
|
+
Or programmatically:
|
|
649
422
|
|
|
650
423
|
```ruby
|
|
651
424
|
EagerEye.configure do |config|
|
|
652
425
|
config.excluded_paths = ["app/legacy/**"]
|
|
653
426
|
config.enabled_detectors = [:loop_association, :serializer_nesting]
|
|
654
|
-
config.severity_levels = { loop_association: :error, missing_counter_cache: :info }
|
|
655
427
|
config.min_severity = :warning
|
|
656
|
-
config.app_path = "app"
|
|
657
428
|
config.fail_on_issues = true
|
|
658
429
|
end
|
|
659
430
|
```
|
|
660
431
|
|
|
661
|
-
##
|
|
432
|
+
## CLI reference
|
|
662
433
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
```yaml
|
|
666
|
-
name: EagerEye
|
|
667
|
-
on: [pull_request]
|
|
668
|
-
|
|
669
|
-
jobs:
|
|
670
|
-
analyze:
|
|
671
|
-
runs-on: ubuntu-latest
|
|
672
|
-
steps:
|
|
673
|
-
- uses: actions/checkout@v4
|
|
674
|
-
- uses: ruby/setup-ruby@v1
|
|
675
|
-
with:
|
|
676
|
-
ruby-version: "3.3"
|
|
677
|
-
- run: gem install eager_eye
|
|
678
|
-
- run: eager_eye app/ --format json > report.json
|
|
679
|
-
- name: Check results
|
|
680
|
-
run: |
|
|
681
|
-
issues=$(cat report.json | ruby -rjson -e 'puts JSON.parse(STDIN.read)["summary"]["total_issues"]')
|
|
682
|
-
if [ "$issues" -gt 0 ]; then
|
|
683
|
-
echo "::warning::Found $issues potential N+1 issues"
|
|
684
|
-
fi
|
|
685
|
-
```
|
|
686
|
-
|
|
687
|
-
See [examples/github_action.yml](examples/github_action.yml) for a complete example with PR annotations.
|
|
688
|
-
|
|
689
|
-
## CLI Reference
|
|
690
|
-
|
|
691
|
-
```
|
|
434
|
+
```text
|
|
692
435
|
Usage: eager_eye [paths] [options]
|
|
693
436
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
### Console (default)
|
|
708
|
-
|
|
709
|
-
```
|
|
710
|
-
EagerEye Analysis Results
|
|
711
|
-
=========================
|
|
712
|
-
|
|
713
|
-
app/controllers/posts_controller.rb
|
|
714
|
-
Line 15: [LoopAssociation] Potential N+1 query: `post.author` called inside iteration
|
|
715
|
-
Suggestion: Consider using `includes(:author)` on the collection before iterating
|
|
716
|
-
|
|
717
|
-
Line 23: [MissingCounterCache] `.count` called on `comments` may cause N+1 queries
|
|
718
|
-
Suggestion: Consider adding `counter_cache: true` to the belongs_to association
|
|
719
|
-
|
|
720
|
-
----------------------------------------
|
|
721
|
-
Total: 2 issues (2 warnings, 0 errors)
|
|
722
|
-
```
|
|
723
|
-
|
|
724
|
-
### JSON
|
|
725
|
-
|
|
726
|
-
```json
|
|
727
|
-
{
|
|
728
|
-
"summary": {
|
|
729
|
-
"total_issues": 2,
|
|
730
|
-
"warnings": 2,
|
|
731
|
-
"errors": 0,
|
|
732
|
-
"files_analyzed": 15
|
|
733
|
-
},
|
|
734
|
-
"issues": [
|
|
735
|
-
{
|
|
736
|
-
"detector": "loop_association",
|
|
737
|
-
"file_path": "app/controllers/posts_controller.rb",
|
|
738
|
-
"line_number": 15,
|
|
739
|
-
"message": "Potential N+1 query: `post.author` called inside iteration",
|
|
740
|
-
"severity": "warning",
|
|
741
|
-
"suggestion": "Consider using `includes(:author)` on the collection"
|
|
742
|
-
}
|
|
743
|
-
]
|
|
744
|
-
}
|
|
437
|
+
-f, --format FORMAT console | json (default: console)
|
|
438
|
+
-e, --exclude PATTERN glob to exclude (repeatable)
|
|
439
|
+
-o, --only DETECTORS comma-separated detector list
|
|
440
|
+
-s, --min-severity LEVEL info | warning | error
|
|
441
|
+
--no-fail always exit 0
|
|
442
|
+
--no-color plain output
|
|
443
|
+
--baseline FILE compare against a previous JSON report;
|
|
444
|
+
only NEW issues are reported (and counted)
|
|
445
|
+
--suggest-fixes print fix diffs without applying
|
|
446
|
+
--fix interactively apply auto-fixes
|
|
447
|
+
--fix --force apply all auto-fixes
|
|
448
|
+
-v, --version
|
|
449
|
+
-h, --help
|
|
745
450
|
```
|
|
746
451
|
|
|
747
452
|
## Limitations
|
|
748
453
|
|
|
749
|
-
EagerEye
|
|
750
|
-
|
|
751
|
-
- **No runtime context** - Cannot know if associations are already eager loaded elsewhere
|
|
752
|
-
- **Heuristic-based** - Uses naming conventions to identify associations (may have false positives)
|
|
753
|
-
- **Ruby code only** - Does not analyze SQL queries or ActiveRecord internals
|
|
754
|
-
- **Cross-file scope** - Cross-file analysis covers model-defined query methods; controller-to-view or service-to-service patterns are not yet tracked
|
|
755
|
-
|
|
756
|
-
For best results, use EagerEye alongside runtime tools like Bullet for comprehensive N+1 detection.
|
|
757
|
-
|
|
758
|
-
## VS Code Extension
|
|
454
|
+
EagerEye is static analysis. That comes with trade-offs:
|
|
759
455
|
|
|
760
|
-
|
|
456
|
+
- **No runtime context** — can't see what `find_each` block actually does at runtime.
|
|
457
|
+
- **Heuristic association detection** — falls back to common name patterns (`author`, `user`, ...) when a model isn't in the parsed set; can over-flag in tiny edge cases.
|
|
458
|
+
- **Cross-file flow** — propagates preloads across same-class methods (controller → its private helpers), but cross-file flow (controller → external service object → iteration) isn't tracked yet.
|
|
459
|
+
- **Ruby code only** — doesn't read SQL or your DB schema.
|
|
761
460
|
|
|
762
|
-
|
|
763
|
-
- Real-time analysis on file save
|
|
764
|
-
- Problem highlighting with squiggly underlines
|
|
765
|
-
- Quick fix actions for common issues
|
|
766
|
-
- Status bar showing issue count
|
|
767
|
-
|
|
768
|
-
**Install:** Search for "EagerEye" in VS Code Extensions or visit the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=hamzagedikkaya.eager-eye).
|
|
461
|
+
Use it alongside [Bullet](https://github.com/flyerhzm/bullet) for a complete picture: static (EagerEye) catches code paths tests don't hit, runtime (Bullet) catches what static can't see.
|
|
769
462
|
|
|
770
463
|
## Development
|
|
771
464
|
|
|
772
465
|
```bash
|
|
773
|
-
# Setup
|
|
774
466
|
bin/setup
|
|
775
|
-
|
|
776
|
-
# Run tests
|
|
777
467
|
bundle exec rspec
|
|
778
|
-
|
|
779
|
-
# Run linter
|
|
780
468
|
bundle exec rubocop
|
|
781
|
-
|
|
782
|
-
# Interactive console
|
|
783
469
|
bin/console
|
|
784
470
|
```
|
|
785
471
|
|
|
786
472
|
## Contributing
|
|
787
473
|
|
|
788
|
-
Bug reports and
|
|
474
|
+
Bug reports and PRs welcome at <https://github.com/hamzagedikkaya/eager_eye>.
|
|
789
475
|
|
|
790
|
-
1. Fork
|
|
791
|
-
2.
|
|
792
|
-
3.
|
|
793
|
-
4.
|
|
794
|
-
5.
|
|
476
|
+
1. Fork
|
|
477
|
+
2. `git checkout -b feature/my-feature`
|
|
478
|
+
3. Add specs (this repo is at ~95% coverage)
|
|
479
|
+
4. `git commit -am 'Add my feature'`
|
|
480
|
+
5. Open a Pull Request
|
|
795
481
|
|
|
796
482
|
## License
|
|
797
483
|
|
|
798
|
-
|
|
484
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
799
485
|
|
|
800
486
|
## Code of Conduct
|
|
801
487
|
|
|
802
|
-
Everyone interacting in
|
|
488
|
+
Everyone interacting in EagerEye's codebases, issue trackers, and discussions is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
|