eager_eye 1.0.9 → 1.0.10
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 +16 -6
- data/README.md +19 -23
- data/lib/eager_eye/comment_parser.rb +0 -4
- data/lib/eager_eye/detectors/pluck_to_array.rb +91 -70
- data/lib/eager_eye/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 27aed443ecc5e83aeb4fe0baa9aa9ccbcec462df45767c8294d867562c9066b5
|
|
4
|
+
data.tar.gz: f2a9f245154a8fe043d9a9f0f68edaf940a5c3f8e2bd3f0255962603ef48d4d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f6f769f5d65827fd4059671674f8947f172b1ef9ff72a82375c25ac3e31c35c81099384e79de97036b8c867613343fd2e078f7c23f59fd68bf5daac8cd8a8a19
|
|
7
|
+
data.tar.gz: f6c0743373d769ec6d147ccb1045b6a667d6015551a705b31eaefe6fc511568ecf06683cb99d7034f2d59cd83997732096d8e7c12b732b42c6ee460b9d152b5c
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.10] - 2025-12-26
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **PluckToArray Severity Levels** - Differentiate between scoped and unscoped pluck usage
|
|
15
|
+
- Scoped `.pluck(:id)` (e.g., `User.active.pluck(:id)`) → **Warning** (default)
|
|
16
|
+
- Unscoped `.all.pluck(:id)` → **Error** (loads entire table into memory)
|
|
17
|
+
- Small arrays with scoped pluck may be acceptable - users can suppress with comments
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **Improved PluckToArray Detection** - Now detects and prioritizes `.all.pluck` patterns
|
|
22
|
+
- Tracks `.all.pluck(:id)` as critical issues (error severity)
|
|
23
|
+
- Detects both variable assignments and inline usage
|
|
24
|
+
- Better suggestions for high-priority cases
|
|
25
|
+
|
|
10
26
|
## [1.0.9] - 2025-12-26
|
|
11
27
|
|
|
12
28
|
### Added
|
|
@@ -19,12 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
19
35
|
- Supports both CamelCase and snake_case detector names
|
|
20
36
|
- Can disable all detectors with `all` keyword
|
|
21
37
|
|
|
22
|
-
### Improved
|
|
23
|
-
|
|
24
|
-
- Enhanced README with table of contents and better organization
|
|
25
|
-
- Improved code readability and documentation structure
|
|
26
|
-
- Updated version badge to v1.0.9
|
|
27
|
-
|
|
28
38
|
## [1.0.8] - 2025-12-25
|
|
29
39
|
|
|
30
40
|
### Added
|
data/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
12
12
|
<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>
|
|
13
|
-
<a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.0.
|
|
13
|
+
<a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.0.10-red.svg" alt="Gem Version"></a>
|
|
14
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
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
16
|
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
26
|
## Table of Contents
|
|
27
|
-
- [Why EagerEye?](#why-eagereye)
|
|
28
27
|
- [Features](#features)
|
|
29
28
|
- [Installation](#installation)
|
|
30
29
|
- [Quick Start](#quick-start)
|
|
@@ -41,31 +40,13 @@
|
|
|
41
40
|
- [Development](#development)
|
|
42
41
|
- [Contributing](#contributing)
|
|
43
42
|
|
|
44
|
-
## Why EagerEye?
|
|
45
|
-
|
|
46
|
-
Unlike runtime tools like Bullet, EagerEye:
|
|
47
|
-
|
|
48
|
-
- **Runs without executing code** - Works in CI pipelines without a test suite
|
|
49
|
-
- **Catches more patterns** - Detects serializer N+1s, missing counter caches, and query methods in loops
|
|
50
|
-
- **Proactive detection** - Finds issues at code review time, not after deployment
|
|
51
|
-
|
|
52
|
-
| Feature | EagerEye | Bullet |
|
|
53
|
-
|---------|----------|--------|
|
|
54
|
-
| Detection method | Static analysis | Runtime |
|
|
55
|
-
| Requires test suite | No | Yes |
|
|
56
|
-
| Serializer N+1 detection | Yes | Limited |
|
|
57
|
-
| Counter cache suggestions | Yes | No |
|
|
58
|
-
| Query methods in loops | Yes | No |
|
|
59
|
-
| CI integration | Native | Requires tests |
|
|
60
|
-
| False positive rate | Higher | Lower |
|
|
61
|
-
|
|
62
43
|
## Features
|
|
63
44
|
|
|
64
45
|
✨ **Detects 7 types of N+1 problems:**
|
|
65
46
|
- Loop associations (queries in iterations)
|
|
66
47
|
- Serializer nesting issues
|
|
67
48
|
- Missing counter caches
|
|
68
|
-
- Custom method queries
|
|
49
|
+
- Custom method queries
|
|
69
50
|
- Count in iteration patterns
|
|
70
51
|
- Callback query N+1s
|
|
71
52
|
- Pluck to array misuse
|
|
@@ -226,10 +207,10 @@ end
|
|
|
226
207
|
|
|
227
208
|
### 4. Custom Method Query (N+1 in query methods)
|
|
228
209
|
|
|
229
|
-
Detects query methods (`.where`, `.find_by`, `.exists?`, etc.) called on associations inside loops.
|
|
210
|
+
Detects query methods (`.where`, `.find_by`, `.exists?`, etc.) called on associations inside loops.
|
|
230
211
|
|
|
231
212
|
```ruby
|
|
232
|
-
# Bad -
|
|
213
|
+
# Bad - where inside loop
|
|
233
214
|
class User < ApplicationRecord
|
|
234
215
|
def supports?(team_name)
|
|
235
216
|
teams.where(name: team_name).exists?
|
|
@@ -336,6 +317,10 @@ user_ids = User.active.pluck(:id) # Query 1: SELECT id FROM users
|
|
|
336
317
|
Post.where(user_id: user_ids) # Query 2: SELECT * FROM posts WHERE user_id IN (1,2,3...)
|
|
337
318
|
# Also holds potentially thousands of IDs in memory
|
|
338
319
|
|
|
320
|
+
# Worse - Loads entire table into memory! (REPORTED AS ERROR)
|
|
321
|
+
user_ids = User.all.pluck(:id) # Query 1: SELECT id FROM users -- ENTIRE TABLE!
|
|
322
|
+
Post.where(user_id: user_ids) # Query 2: SELECT * FROM posts WHERE user_id IN (...)
|
|
323
|
+
|
|
339
324
|
# Bad - Same problem with map
|
|
340
325
|
user_ids = users.map(&:id)
|
|
341
326
|
Post.where(user_id: user_ids)
|
|
@@ -345,11 +330,22 @@ Post.where(user_id: User.active.select(:id))
|
|
|
345
330
|
# Single query: SELECT * FROM posts WHERE user_id IN (SELECT id FROM users WHERE active = true)
|
|
346
331
|
```
|
|
347
332
|
|
|
333
|
+
**Severity Levels:**
|
|
334
|
+
|
|
335
|
+
- ⚠️ **Warning (default)** - Scoped `.pluck(:id)` (e.g., `User.active.pluck(:id)`)
|
|
336
|
+
- Two queries and memory overhead with moderately-sized arrays
|
|
337
|
+
- Small arrays may be acceptable
|
|
338
|
+
|
|
339
|
+
- 🔴 **Error** - Unscoped `.all.pluck(:id)` (e.g., `User.all.pluck(:id)`)
|
|
340
|
+
- Loads entire table into memory
|
|
341
|
+
- Highly inefficient and should always be refactored
|
|
342
|
+
|
|
348
343
|
**Performance comparison with 10,000 users:**
|
|
349
344
|
|
|
350
345
|
| Approach | Queries | Memory | Time |
|
|
351
346
|
|----------|---------|--------|------|
|
|
352
347
|
| `pluck` + `where` | 2 | ~80KB for IDs | ~45ms |
|
|
348
|
+
| `.all.pluck` + `where` | 2 | ~40KB+ for all IDs | ~100ms+ |
|
|
353
349
|
| `select` subquery | 1 | None | ~20ms |
|
|
354
350
|
|
|
355
351
|
## Inline Suppression
|
|
@@ -72,22 +72,18 @@ module EagerEye
|
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
def detect_block_or_inline_directive(line)
|
|
75
|
-
# Check for eager_eye:disable-block explicitly
|
|
76
75
|
if line =~ BLOCK_START_PATTERN && !code_before_comment?(line)
|
|
77
76
|
detectors = parse_detector_names(::Regexp.last_match(1) || "all")
|
|
78
77
|
return { type: :block_start, detectors: detectors }
|
|
79
78
|
end
|
|
80
79
|
|
|
81
|
-
# Check for eager_eye:disable (either inline or block start)
|
|
82
80
|
return unless line =~ INLINE_DISABLE_PATTERN
|
|
83
81
|
|
|
84
82
|
detectors = parse_detector_names(::Regexp.last_match(1) || "all")
|
|
85
83
|
|
|
86
|
-
# If there's code before the comment, it's inline (same line only)
|
|
87
84
|
if code_before_comment?(line)
|
|
88
85
|
{ type: :inline, detectors: detectors }
|
|
89
86
|
else
|
|
90
|
-
# No code before comment, so it's a block start
|
|
91
87
|
{ type: :block_start, detectors: detectors }
|
|
92
88
|
end
|
|
93
89
|
end
|
|
@@ -12,126 +12,136 @@ module EagerEye
|
|
|
12
12
|
@file_path = file_path
|
|
13
13
|
@pluck_variables = {}
|
|
14
14
|
@map_id_variables = {}
|
|
15
|
+
@critical_pluck_variables = {}
|
|
15
16
|
|
|
16
17
|
return @issues unless ast
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
collect_map_id_assignments(ast)
|
|
20
|
-
find_where_with_pluck_var(ast)
|
|
21
|
-
|
|
19
|
+
visit(ast)
|
|
22
20
|
@issues
|
|
23
21
|
end
|
|
24
22
|
|
|
25
23
|
private
|
|
26
24
|
|
|
27
|
-
def
|
|
25
|
+
def visit(node)
|
|
28
26
|
return unless node.is_a?(Parser::AST::Node)
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
value = node.children[1]
|
|
33
|
-
|
|
34
|
-
@pluck_variables[var_name] = node.loc.line if pluck_call?(value)
|
|
35
|
-
end
|
|
28
|
+
collect_assignments(node)
|
|
29
|
+
check_where_calls(node)
|
|
36
30
|
|
|
37
|
-
node.children.each
|
|
38
|
-
collect_pluck_assignments(child)
|
|
39
|
-
end
|
|
31
|
+
node.children.each { |child| visit(child) }
|
|
40
32
|
end
|
|
41
33
|
|
|
42
|
-
def
|
|
43
|
-
return unless
|
|
34
|
+
def collect_assignments(node)
|
|
35
|
+
return unless local_variable_assignment?(node)
|
|
44
36
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
value = node.children[1]
|
|
37
|
+
var_name = node.children[0]
|
|
38
|
+
value = node.children[1]
|
|
48
39
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
node.children.each do |child|
|
|
53
|
-
collect_map_id_assignments(child)
|
|
54
|
-
end
|
|
40
|
+
@critical_pluck_variables[var_name] = node.loc.line if all_pluck_call?(value)
|
|
41
|
+
@pluck_variables[var_name] = node.loc.line if pluck_call?(value)
|
|
42
|
+
@map_id_variables[var_name] = node.loc.line if map_id_call?(value)
|
|
55
43
|
end
|
|
56
44
|
|
|
57
|
-
def
|
|
58
|
-
return unless
|
|
45
|
+
def check_where_calls(node)
|
|
46
|
+
return unless where_call?(node)
|
|
59
47
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
node.children.each do |child|
|
|
63
|
-
find_where_with_pluck_var(child)
|
|
64
|
-
end
|
|
48
|
+
add_critical_issue(node) if critical_pluck?(node)
|
|
49
|
+
add_issue(node) if regular_pluck?(node)
|
|
65
50
|
end
|
|
66
51
|
|
|
67
52
|
def local_variable_assignment?(node)
|
|
68
53
|
node.type == :lvasgn
|
|
69
54
|
end
|
|
70
55
|
|
|
56
|
+
def where_call?(node)
|
|
57
|
+
node.type == :send && node.children[1] == :where
|
|
58
|
+
end
|
|
59
|
+
|
|
71
60
|
def pluck_call?(node)
|
|
72
|
-
return false unless node.is_a?(Parser::AST::Node)
|
|
73
|
-
return false unless node.type == :send
|
|
61
|
+
return false unless node.is_a?(Parser::AST::Node) && node.type == :send
|
|
74
62
|
|
|
75
|
-
|
|
76
|
-
%i[pluck ids].include?(
|
|
63
|
+
method = node.children[1]
|
|
64
|
+
%i[pluck ids].include?(method)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def all_pluck_call?(node)
|
|
68
|
+
return false unless pluck_call?(node)
|
|
69
|
+
|
|
70
|
+
receiver = node.children[0]
|
|
71
|
+
receiver.is_a?(Parser::AST::Node) && receiver.type == :send &&
|
|
72
|
+
receiver.children[1] == :all
|
|
77
73
|
end
|
|
78
74
|
|
|
79
75
|
def map_id_call?(node)
|
|
80
76
|
return false unless node.is_a?(Parser::AST::Node)
|
|
81
77
|
|
|
82
|
-
|
|
83
|
-
when :block then block_map_call?(node)
|
|
84
|
-
when :send then send_map_id_call?(node)
|
|
85
|
-
else false
|
|
86
|
-
end
|
|
78
|
+
block_map?(node) || send_map?(node)
|
|
87
79
|
end
|
|
88
80
|
|
|
89
|
-
def
|
|
90
|
-
|
|
91
|
-
return false unless send_node&.type == :send
|
|
81
|
+
def block_map?(node)
|
|
82
|
+
return false unless node.type == :block
|
|
92
83
|
|
|
93
|
-
|
|
84
|
+
send_node = node.children[0]
|
|
85
|
+
send_node&.type == :send && %i[map collect].include?(send_node.children[1])
|
|
94
86
|
end
|
|
95
87
|
|
|
96
|
-
def
|
|
97
|
-
|
|
98
|
-
return false unless %i[map collect].include?(method_name)
|
|
88
|
+
def send_map?(node)
|
|
89
|
+
return false unless node.type == :send
|
|
99
90
|
|
|
100
|
-
node.children[
|
|
91
|
+
method = node.children[1]
|
|
92
|
+
%i[map collect].include?(method) &&
|
|
93
|
+
node.children[2..].any? { |arg| symbol_to_proc_id?(arg) }
|
|
101
94
|
end
|
|
102
95
|
|
|
103
96
|
def symbol_to_proc_id?(node)
|
|
104
|
-
return false unless node.is_a?(Parser::AST::Node)
|
|
105
|
-
return false unless node.type == :block_pass
|
|
97
|
+
return false unless node.is_a?(Parser::AST::Node) && node.type == :block_pass
|
|
106
98
|
|
|
107
|
-
|
|
108
|
-
|
|
99
|
+
sym = node.children[0]
|
|
100
|
+
sym&.type == :sym && %i[id to_i].include?(sym.children[0])
|
|
101
|
+
end
|
|
109
102
|
|
|
110
|
-
|
|
103
|
+
def regular_pluck?(node)
|
|
104
|
+
where_args = node.children[2..]
|
|
105
|
+
where_args.any? { |arg| pluck_var_in_hash?(arg) }
|
|
111
106
|
end
|
|
112
107
|
|
|
113
|
-
def
|
|
114
|
-
|
|
115
|
-
|
|
108
|
+
def critical_pluck?(node)
|
|
109
|
+
where_args = node.children[2..]
|
|
110
|
+
where_args.any? { |arg| critical_pluck_in_hash?(arg) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def pluck_var_in_hash?(node)
|
|
114
|
+
return false unless node.is_a?(Parser::AST::Node) && node.type == :hash
|
|
116
115
|
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
node.children.any? do |pair|
|
|
117
|
+
next false unless pair.type == :pair
|
|
118
|
+
|
|
119
|
+
pluck_value?(pair.children[1])
|
|
120
|
+
end
|
|
119
121
|
end
|
|
120
122
|
|
|
121
|
-
def
|
|
122
|
-
return false unless node.is_a?(Parser::AST::Node)
|
|
123
|
-
return false unless node.type == :hash
|
|
123
|
+
def critical_pluck_in_hash?(node)
|
|
124
|
+
return false unless node.is_a?(Parser::AST::Node) && node.type == :hash
|
|
124
125
|
|
|
125
126
|
node.children.any? do |pair|
|
|
126
127
|
next false unless pair.type == :pair
|
|
127
128
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
129
|
+
critical_value?(pair.children[1])
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def pluck_value?(value)
|
|
134
|
+
return false unless value.type == :lvar
|
|
135
|
+
|
|
136
|
+
var_name = value.children[0]
|
|
137
|
+
@pluck_variables.key?(var_name) || @map_id_variables.key?(var_name)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def critical_value?(value)
|
|
141
|
+
if value.type == :lvar
|
|
142
|
+
@critical_pluck_variables.key?(value.children[0])
|
|
143
|
+
else
|
|
144
|
+
all_pluck_call?(value)
|
|
135
145
|
end
|
|
136
146
|
end
|
|
137
147
|
|
|
@@ -140,7 +150,18 @@ module EagerEye
|
|
|
140
150
|
file_path: @file_path,
|
|
141
151
|
line_number: node.loc.line,
|
|
142
152
|
message: "Using plucked/mapped array in `where` causes two queries and holds IDs in memory",
|
|
143
|
-
suggestion: "Use `.select(:id)` subquery: `Model.where(col: OtherModel.condition.select(:id))`"
|
|
153
|
+
suggestion: "Use `.select(:id)` subquery: `Model.where(col: OtherModel.condition.select(:id))`",
|
|
154
|
+
severity: :warning
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def add_critical_issue(node)
|
|
159
|
+
@issues << create_issue(
|
|
160
|
+
file_path: @file_path,
|
|
161
|
+
line_number: node.loc.line,
|
|
162
|
+
message: "Using `.all.pluck(:id)` in `where` loads entire table into memory and is highly inefficient",
|
|
163
|
+
suggestion: "Use `.select(:id)` subquery instead: `Model.where(col: OtherModel.select(:id))`",
|
|
164
|
+
severity: :error
|
|
144
165
|
)
|
|
145
166
|
end
|
|
146
167
|
end
|
data/lib/eager_eye/version.rb
CHANGED