eager_eye 0.1.0 → 0.2.1
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 +15 -0
- data/README.md +32 -1
- data/lib/eager_eye/analyzer.rb +2 -1
- data/lib/eager_eye/configuration.rb +1 -1
- data/lib/eager_eye/detectors/custom_method_query.rb +145 -0
- data/lib/eager_eye/version.rb +1 -1
- data/lib/eager_eye.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2c1781e66205a83557a9751b64f19c44d70f90efbd04423d63e847d4e4a06c6e
|
|
4
|
+
data.tar.gz: eac8bb2e4a8b86017804b2c9662074ece08346a5303b5760bdba6dc7d939b3d9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9a9fec60ec826824ef8b331f95f33918265f51ea027b43f60852656a0b1420622b9405a78f0fdc4ba3e32c9a30159deb5104fe7432b35fdf9bdea8e5325aa120
|
|
7
|
+
data.tar.gz: fe4032176ada8b10c84ee56c48a8ee60aadbf7c6547e98e0f36def0226a119a7383e6258a060a197b4b6276667fd65f30707dbaeda155ff9410551b2111167ed
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2025-12-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **New Detector: `CustomMethodQuery`** - Detects query methods called inside iteration blocks
|
|
15
|
+
- Catches `.where`, `.find_by`, `.find_by!`, `.exists?` patterns that Bullet cannot detect
|
|
16
|
+
- Detects `.find`, `.first`, `.last`, `.take` inside loops
|
|
17
|
+
- Detects aggregation methods: `.pluck`, `.ids`, `.count`, `.sum`, `.average`, `.minimum`, `.maximum`
|
|
18
|
+
- Provides suggestions for preloading data before loops
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Updated default `enabled_detectors` to include `:custom_method_query`
|
|
23
|
+
- Updated README with new detector documentation and comparison table
|
|
24
|
+
|
|
10
25
|
## [0.1.0] - 2025-12-15
|
|
11
26
|
|
|
12
27
|
### Added
|
data/README.md
CHANGED
|
@@ -14,7 +14,7 @@ EagerEye analyzes your Ruby code without running it, using AST (Abstract Syntax
|
|
|
14
14
|
Unlike runtime tools like Bullet, EagerEye:
|
|
15
15
|
|
|
16
16
|
- **Runs without executing code** - Works in CI pipelines without a test suite
|
|
17
|
-
- **Catches more patterns** - Detects serializer N+1s
|
|
17
|
+
- **Catches more patterns** - Detects serializer N+1s, missing counter caches, and query methods in loops
|
|
18
18
|
- **Proactive detection** - Finds issues at code review time, not after deployment
|
|
19
19
|
|
|
20
20
|
| Feature | EagerEye | Bullet |
|
|
@@ -23,6 +23,7 @@ Unlike runtime tools like Bullet, EagerEye:
|
|
|
23
23
|
| Requires test suite | No | Yes |
|
|
24
24
|
| Serializer N+1 detection | Yes | Limited |
|
|
25
25
|
| Counter cache suggestions | Yes | No |
|
|
26
|
+
| Query methods in loops | Yes | No |
|
|
26
27
|
| CI integration | Native | Requires tests |
|
|
27
28
|
| False positive rate | Higher | Lower |
|
|
28
29
|
|
|
@@ -146,6 +147,35 @@ belongs_to :post, counter_cache: true
|
|
|
146
147
|
post.comments_count
|
|
147
148
|
```
|
|
148
149
|
|
|
150
|
+
### 4. Custom Method Query (N+1 in query methods)
|
|
151
|
+
|
|
152
|
+
Detects query methods (`.where`, `.find_by`, `.exists?`, etc.) called on associations inside loops. **These patterns are invisible to Bullet.**
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
# Bad - Bullet CANNOT catch this
|
|
156
|
+
class User < ApplicationRecord
|
|
157
|
+
def supports?(team_name)
|
|
158
|
+
teams.where(name: team_name).exists?
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
@users.each do |user|
|
|
163
|
+
user.supports?("Lakers") # Query for each user!
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Bad - find_by inside loop
|
|
167
|
+
@orders.each do |order|
|
|
168
|
+
order.line_items.find_by(featured: true)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Good - Preload and filter in Ruby
|
|
172
|
+
@users.includes(:teams).each do |user|
|
|
173
|
+
user.teams.any? { |t| t.name == "Lakers" }
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Detected methods:** `where`, `find_by`, `find_by!`, `exists?`, `find`, `first`, `last`, `take`, `pluck`, `ids`, `count`, `sum`, `average`, `minimum`, `maximum`
|
|
178
|
+
|
|
149
179
|
## Configuration
|
|
150
180
|
|
|
151
181
|
### Config File (.eager_eye.yml)
|
|
@@ -161,6 +191,7 @@ enabled_detectors:
|
|
|
161
191
|
- loop_association
|
|
162
192
|
- serializer_nesting
|
|
163
193
|
- missing_counter_cache
|
|
194
|
+
- custom_method_query
|
|
164
195
|
|
|
165
196
|
# Base path to analyze (default: app)
|
|
166
197
|
app_path: app
|
data/lib/eager_eye/analyzer.rb
CHANGED
|
@@ -7,7 +7,8 @@ module EagerEye
|
|
|
7
7
|
DETECTOR_CLASSES = {
|
|
8
8
|
loop_association: Detectors::LoopAssociation,
|
|
9
9
|
serializer_nesting: Detectors::SerializerNesting,
|
|
10
|
-
missing_counter_cache: Detectors::MissingCounterCache
|
|
10
|
+
missing_counter_cache: Detectors::MissingCounterCache,
|
|
11
|
+
custom_method_query: Detectors::CustomMethodQuery
|
|
11
12
|
}.freeze
|
|
12
13
|
|
|
13
14
|
attr_reader :paths, :issues
|
|
@@ -4,7 +4,7 @@ module EagerEye
|
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :excluded_paths, :enabled_detectors, :app_path, :fail_on_issues
|
|
6
6
|
|
|
7
|
-
DEFAULT_DETECTORS = %i[loop_association serializer_nesting missing_counter_cache].freeze
|
|
7
|
+
DEFAULT_DETECTORS = %i[loop_association serializer_nesting missing_counter_cache custom_method_query].freeze
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
10
10
|
@excluded_paths = []
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
module Detectors
|
|
5
|
+
class CustomMethodQuery < Base
|
|
6
|
+
QUERY_METHODS = %i[
|
|
7
|
+
where
|
|
8
|
+
find_by
|
|
9
|
+
find_by!
|
|
10
|
+
exists?
|
|
11
|
+
find
|
|
12
|
+
first
|
|
13
|
+
last
|
|
14
|
+
take
|
|
15
|
+
pluck
|
|
16
|
+
ids
|
|
17
|
+
count
|
|
18
|
+
sum
|
|
19
|
+
average
|
|
20
|
+
minimum
|
|
21
|
+
maximum
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
ITERATION_METHODS = %i[each map select find_all reject collect detect find_index flat_map].freeze
|
|
25
|
+
|
|
26
|
+
def self.detector_name
|
|
27
|
+
:custom_method_query
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def detect(ast, file_path)
|
|
31
|
+
return [] unless ast
|
|
32
|
+
|
|
33
|
+
@issues = []
|
|
34
|
+
@file_path = file_path
|
|
35
|
+
|
|
36
|
+
find_iteration_blocks(ast) do |block_body, block_var|
|
|
37
|
+
check_block_for_query_methods(block_body, block_var)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@issues
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def find_iteration_blocks(node, &block)
|
|
46
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
47
|
+
|
|
48
|
+
if iteration_block?(node)
|
|
49
|
+
block_var = extract_block_variable(node)
|
|
50
|
+
block_body = extract_block_body(node)
|
|
51
|
+
yield(block_body, block_var) if block_var && block_body
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
node.children.each do |child|
|
|
55
|
+
find_iteration_blocks(child, &block)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def iteration_block?(node)
|
|
60
|
+
return false unless node.type == :block
|
|
61
|
+
|
|
62
|
+
send_node = node.children[0]
|
|
63
|
+
return false unless send_node&.type == :send
|
|
64
|
+
|
|
65
|
+
method_name = send_node.children[1]
|
|
66
|
+
ITERATION_METHODS.include?(method_name)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_block_for_query_methods(node, block_var)
|
|
70
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
71
|
+
|
|
72
|
+
add_issue(node) if query_chain_on_association?(node, block_var)
|
|
73
|
+
|
|
74
|
+
node.children.each do |child|
|
|
75
|
+
check_block_for_query_methods(child, block_var)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def query_chain_on_association?(node, block_var)
|
|
80
|
+
return false unless node.type == :send
|
|
81
|
+
|
|
82
|
+
method_name = node.children[1]
|
|
83
|
+
return false unless QUERY_METHODS.include?(method_name)
|
|
84
|
+
|
|
85
|
+
receiver = node.children[0]
|
|
86
|
+
receiver_chain_starts_with?(receiver, block_var)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def receiver_chain_starts_with?(node, block_var)
|
|
90
|
+
return false unless node.is_a?(Parser::AST::Node)
|
|
91
|
+
|
|
92
|
+
case node.type
|
|
93
|
+
when :lvar
|
|
94
|
+
node.children[0] == block_var
|
|
95
|
+
when :send
|
|
96
|
+
receiver_chain_starts_with?(node.children[0], block_var)
|
|
97
|
+
else
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def extract_block_variable(block_node)
|
|
103
|
+
args_node = block_node.children[1]
|
|
104
|
+
return nil unless args_node&.type == :args
|
|
105
|
+
|
|
106
|
+
first_arg = args_node.children[0]
|
|
107
|
+
return nil unless first_arg&.type == :arg
|
|
108
|
+
|
|
109
|
+
first_arg.children[0]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def extract_block_body(block_node)
|
|
113
|
+
block_node.children[2]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def add_issue(node)
|
|
117
|
+
method_name = node.children[1]
|
|
118
|
+
association_chain = reconstruct_chain(node.children[0])
|
|
119
|
+
|
|
120
|
+
@issues << create_issue(
|
|
121
|
+
file_path: @file_path,
|
|
122
|
+
line_number: node.loc.line,
|
|
123
|
+
message: "Query method `.#{method_name}` called on `#{association_chain}` inside iteration",
|
|
124
|
+
severity: :warning,
|
|
125
|
+
suggestion: "This query executes on each iteration. Consider preloading data or restructuring the query."
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def reconstruct_chain(node)
|
|
130
|
+
return "" unless node.is_a?(Parser::AST::Node)
|
|
131
|
+
|
|
132
|
+
case node.type
|
|
133
|
+
when :lvar
|
|
134
|
+
node.children[0].to_s
|
|
135
|
+
when :send
|
|
136
|
+
receiver_str = reconstruct_chain(node.children[0])
|
|
137
|
+
method = node.children[1]
|
|
138
|
+
receiver_str.empty? ? method.to_s : "#{receiver_str}.#{method}"
|
|
139
|
+
else
|
|
140
|
+
""
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
data/lib/eager_eye/version.rb
CHANGED
data/lib/eager_eye.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "eager_eye/detectors/base"
|
|
|
7
7
|
require_relative "eager_eye/detectors/loop_association"
|
|
8
8
|
require_relative "eager_eye/detectors/serializer_nesting"
|
|
9
9
|
require_relative "eager_eye/detectors/missing_counter_cache"
|
|
10
|
+
require_relative "eager_eye/detectors/custom_method_query"
|
|
10
11
|
require_relative "eager_eye/analyzer"
|
|
11
12
|
require_relative "eager_eye/reporters/base"
|
|
12
13
|
require_relative "eager_eye/reporters/console"
|
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: 0.1
|
|
4
|
+
version: 0.2.1
|
|
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-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ast
|
|
@@ -61,6 +61,7 @@ files:
|
|
|
61
61
|
- lib/eager_eye/cli.rb
|
|
62
62
|
- lib/eager_eye/configuration.rb
|
|
63
63
|
- lib/eager_eye/detectors/base.rb
|
|
64
|
+
- lib/eager_eye/detectors/custom_method_query.rb
|
|
64
65
|
- lib/eager_eye/detectors/loop_association.rb
|
|
65
66
|
- lib/eager_eye/detectors/missing_counter_cache.rb
|
|
66
67
|
- lib/eager_eye/detectors/serializer_nesting.rb
|