eager_eye 1.0.9 → 1.1.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/.rubocop.yml +3 -3
- data/CHANGELOG.md +18 -6
- data/CONTRIBUTING.md +1 -1
- data/README.md +23 -36
- data/lib/eager_eye/analyzer.rb +34 -6
- data/lib/eager_eye/association_parser.rb +110 -0
- data/lib/eager_eye/comment_parser.rb +0 -4
- data/lib/eager_eye/detectors/base.rb +28 -0
- data/lib/eager_eye/detectors/loop_association.rb +81 -70
- data/lib/eager_eye/detectors/pluck_to_array.rb +92 -71
- 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: 03025eeca47525318e9dbd1ffe0046bcd6647c16bc5696a1fd0b7906906ebc9d
|
|
4
|
+
data.tar.gz: c9fa809e3bcc95a6ea20d6f9ef18ca45241ad96e325fef76b0aa90e79b19e3a3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c3faea6fb82cda3d118584970dba7bb88fa2fa1024dd6906d03f89699ff93b5f4d2a487b6c598eb9ee6bc18ae222b308f959d3cefbaed7d54c808aba93bc2fb2
|
|
7
|
+
data.tar.gz: 355d9b1c678b755ff679492183e7046b3edb511bff6e8fe3ff0990f091274917877cf8495984cb8315fe1096b0308a3528aff8c56fc6f1ed22270aa4257b0d03
|
data/.rubocop.yml
CHANGED
|
@@ -19,6 +19,9 @@ Metrics/BlockLength:
|
|
|
19
19
|
- "*.gemspec"
|
|
20
20
|
- "lib/eager_eye/railtie.rb"
|
|
21
21
|
|
|
22
|
+
Metrics/ClassLength:
|
|
23
|
+
Max: 165
|
|
24
|
+
|
|
22
25
|
Metrics/ParameterLists:
|
|
23
26
|
Max: 6
|
|
24
27
|
|
|
@@ -28,8 +31,5 @@ Metrics/AbcSize:
|
|
|
28
31
|
Metrics/MethodLength:
|
|
29
32
|
Max: 30
|
|
30
33
|
|
|
31
|
-
Metrics/ClassLength:
|
|
32
|
-
Max: 150
|
|
33
|
-
|
|
34
34
|
Layout/FirstArrayElementIndentation:
|
|
35
35
|
EnforcedStyle: consistent
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.1.0] - 2025-12-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Association Scope Preloading Detection** - LoopAssociation now recognizes associations with built-in preloading
|
|
15
|
+
- Detects `has_many :posts, -> { includes(:comments) }` patterns
|
|
16
|
+
- Recognizes scope-defined preloads to reduce false positives
|
|
17
|
+
- Parses model files to extract association definitions and preload scopes
|
|
18
|
+
|
|
19
|
+
## [1.0.10] - 2025-12-27
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **PluckToArray Severity Levels** - Scoped pluck is warning, `.all.pluck` is error
|
|
24
|
+
- Scoped `.pluck(:id)` → **Warning** (acceptable for small arrays)
|
|
25
|
+
- Unscoped `.all.pluck(:id)` → **Error** (loads entire table)
|
|
26
|
+
- Improved detection and suggestions for critical patterns
|
|
27
|
+
|
|
10
28
|
## [1.0.9] - 2025-12-26
|
|
11
29
|
|
|
12
30
|
### Added
|
|
@@ -19,12 +37,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
19
37
|
- Supports both CamelCase and snake_case detector names
|
|
20
38
|
- Can disable all detectors with `all` keyword
|
|
21
39
|
|
|
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
40
|
## [1.0.8] - 2025-12-25
|
|
29
41
|
|
|
30
42
|
### Added
|
data/CONTRIBUTING.md
CHANGED
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.1.0-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
|
|
@@ -172,6 +153,17 @@ posts.each { |post| post.comments.size } # No warning
|
|
|
172
153
|
@user.posts.each do |post|
|
|
173
154
|
post.comments # No warning - single user, no N+1
|
|
174
155
|
end
|
|
156
|
+
|
|
157
|
+
# Scope-defined preloads are recognized (v1.1.0+)
|
|
158
|
+
# In Post model:
|
|
159
|
+
class Post < ApplicationRecord
|
|
160
|
+
has_many :comments, -> { includes(:author) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# In controller - EagerEye recognizes comments are already preloaded via scope!
|
|
164
|
+
posts.each do |post|
|
|
165
|
+
post.comments.map(&:author) # No warning - preloaded via scope
|
|
166
|
+
end
|
|
175
167
|
```
|
|
176
168
|
|
|
177
169
|
### 2. Serializer Nesting (N+1 in serializers)
|
|
@@ -226,10 +218,10 @@ end
|
|
|
226
218
|
|
|
227
219
|
### 4. Custom Method Query (N+1 in query methods)
|
|
228
220
|
|
|
229
|
-
Detects query methods (`.where`, `.find_by`, `.exists?`, etc.) called on associations inside loops.
|
|
221
|
+
Detects query methods (`.where`, `.find_by`, `.exists?`, etc.) called on associations inside loops.
|
|
230
222
|
|
|
231
223
|
```ruby
|
|
232
|
-
# Bad -
|
|
224
|
+
# Bad - where inside loop
|
|
233
225
|
class User < ApplicationRecord
|
|
234
226
|
def supports?(team_name)
|
|
235
227
|
teams.where(name: team_name).exists?
|
|
@@ -332,25 +324,20 @@ Detects when `.pluck(:id)` or `.map(&:id)` results are used in `where` clauses i
|
|
|
332
324
|
|
|
333
325
|
```ruby
|
|
334
326
|
# Bad - Two queries + memory overhead
|
|
335
|
-
user_ids = User.active.pluck(:id)
|
|
336
|
-
Post.where(user_id: user_ids)
|
|
337
|
-
# Also holds potentially thousands of IDs in memory
|
|
327
|
+
user_ids = User.active.pluck(:id)
|
|
328
|
+
Post.where(user_id: user_ids) # ⚠️ Warning
|
|
338
329
|
|
|
339
|
-
#
|
|
340
|
-
user_ids =
|
|
330
|
+
# Worse - Loads entire table! 🔴 Error
|
|
331
|
+
user_ids = User.all.pluck(:id)
|
|
341
332
|
Post.where(user_id: user_ids)
|
|
342
333
|
|
|
343
|
-
# Good - Single subquery
|
|
334
|
+
# Good - Single subquery
|
|
344
335
|
Post.where(user_id: User.active.select(:id))
|
|
345
|
-
# Single query: SELECT * FROM posts WHERE user_id IN (SELECT id FROM users WHERE active = true)
|
|
346
336
|
```
|
|
347
337
|
|
|
348
|
-
**
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|----------|---------|--------|------|
|
|
352
|
-
| `pluck` + `where` | 2 | ~80KB for IDs | ~45ms |
|
|
353
|
-
| `select` subquery | 1 | None | ~20ms |
|
|
338
|
+
**Severity:**
|
|
339
|
+
- ⚠️ **Warning** - Scoped `.pluck(:id)` (two queries, memory overhead)
|
|
340
|
+
- 🔴 **Error** - Unscoped `.all.pluck(:id)` (loads entire table)
|
|
354
341
|
|
|
355
342
|
## Inline Suppression
|
|
356
343
|
|
data/lib/eager_eye/analyzer.rb
CHANGED
|
@@ -14,24 +14,49 @@ module EagerEye
|
|
|
14
14
|
pluck_to_array: Detectors::PluckToArray
|
|
15
15
|
}.freeze
|
|
16
16
|
|
|
17
|
-
attr_reader :paths, :issues
|
|
17
|
+
attr_reader :paths, :issues, :association_preloads
|
|
18
18
|
|
|
19
19
|
def initialize(paths: nil)
|
|
20
20
|
@paths = Array(paths || EagerEye.configuration.app_path)
|
|
21
21
|
@issues = []
|
|
22
|
+
@association_preloads = {}
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def run
|
|
25
26
|
@issues = []
|
|
27
|
+
collect_association_preloads
|
|
28
|
+
analyze_files
|
|
29
|
+
@issues
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def collect_association_preloads
|
|
35
|
+
model_files.each do |file_path|
|
|
36
|
+
source = File.read(file_path)
|
|
37
|
+
ast = parse_source(source)
|
|
38
|
+
next unless ast
|
|
26
39
|
|
|
27
|
-
|
|
28
|
-
|
|
40
|
+
model_name = extract_model_name(file_path)
|
|
41
|
+
parser = AssociationParser.new
|
|
42
|
+
parser.parse_model(ast, model_name)
|
|
43
|
+
@association_preloads.merge!(parser.preloaded_associations)
|
|
29
44
|
end
|
|
45
|
+
rescue StandardError
|
|
46
|
+
# Silently skip errors in association parsing
|
|
47
|
+
end
|
|
30
48
|
|
|
31
|
-
|
|
49
|
+
def model_files
|
|
50
|
+
Dir.glob(File.join(@paths[0], "models", "**", "*.rb"))
|
|
32
51
|
end
|
|
33
52
|
|
|
34
|
-
|
|
53
|
+
def extract_model_name(file_path)
|
|
54
|
+
File.basename(file_path, ".rb").camelize
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def analyze_files
|
|
58
|
+
ruby_files.each { |file_path| analyze_file(file_path) }
|
|
59
|
+
end
|
|
35
60
|
|
|
36
61
|
def ruby_files
|
|
37
62
|
all_files = paths.flat_map do |path|
|
|
@@ -61,7 +86,10 @@ module EagerEye
|
|
|
61
86
|
comment_parser = CommentParser.new(source)
|
|
62
87
|
|
|
63
88
|
enabled_detectors.each do |detector|
|
|
64
|
-
|
|
89
|
+
args = [ast, file_path]
|
|
90
|
+
args << @association_preloads if detector.is_a?(Detectors::LoopAssociation)
|
|
91
|
+
|
|
92
|
+
file_issues = detector.detect(*args)
|
|
65
93
|
|
|
66
94
|
# Filter suppressed issues
|
|
67
95
|
file_issues.reject! do |issue|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
class AssociationParser
|
|
5
|
+
ASSOCIATION_METHODS = %i[has_many has_one belongs_to has_and_belongs_to_many].freeze
|
|
6
|
+
|
|
7
|
+
attr_reader :preloaded_associations
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@preloaded_associations = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def parse_model(ast, model_name)
|
|
14
|
+
return unless ast
|
|
15
|
+
|
|
16
|
+
traverse(ast, model_name)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def traverse(node, model_name)
|
|
22
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
23
|
+
|
|
24
|
+
check_association_definition(node, model_name)
|
|
25
|
+
node.children.each { |child| traverse(child, model_name) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def check_association_definition(node, model_name)
|
|
29
|
+
return unless node.type == :send
|
|
30
|
+
return unless node.children[0].nil? # receiver is nil (class context)
|
|
31
|
+
|
|
32
|
+
method_name = node.children[1]
|
|
33
|
+
return unless ASSOCIATION_METHODS.include?(method_name)
|
|
34
|
+
|
|
35
|
+
association_name = extract_association_name(node)
|
|
36
|
+
return unless association_name
|
|
37
|
+
|
|
38
|
+
preloaded = extract_preloaded_associations(node)
|
|
39
|
+
return if preloaded.empty?
|
|
40
|
+
|
|
41
|
+
@preloaded_associations["#{model_name}##{association_name}"] = preloaded
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def extract_association_name(node)
|
|
45
|
+
args = node.children[2..]
|
|
46
|
+
return nil if args.empty?
|
|
47
|
+
|
|
48
|
+
first_arg = args[0]
|
|
49
|
+
return nil unless first_arg&.type == :sym
|
|
50
|
+
|
|
51
|
+
first_arg.children[0]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def extract_preloaded_associations(node)
|
|
55
|
+
preloaded = Set.new
|
|
56
|
+
args = node.children[2..]
|
|
57
|
+
return preloaded if args.empty?
|
|
58
|
+
|
|
59
|
+
# Check for block with includes/preload/eager_load
|
|
60
|
+
block_node = args.find { |arg| arg&.type == :block }
|
|
61
|
+
return preloaded unless block_node
|
|
62
|
+
|
|
63
|
+
extract_from_block(block_node, preloaded)
|
|
64
|
+
preloaded
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def extract_from_block(block_node, preloaded)
|
|
68
|
+
block_body = block_node.children[2]
|
|
69
|
+
traverse_for_preloads(block_body, preloaded)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def traverse_for_preloads(node, preloaded)
|
|
73
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
74
|
+
|
|
75
|
+
extract_includes_from_method(node, preloaded) if preload_call?(node)
|
|
76
|
+
|
|
77
|
+
node.children.each { |child| traverse_for_preloads(child, preloaded) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def preload_call?(node)
|
|
81
|
+
return false unless node.type == :send
|
|
82
|
+
|
|
83
|
+
method = node.children[1]
|
|
84
|
+
%i[includes preload eager_load].include?(method)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def extract_includes_from_method(node, included)
|
|
88
|
+
args = node.children[2..]
|
|
89
|
+
return if args.empty?
|
|
90
|
+
|
|
91
|
+
args.each { |arg| add_included_sym(arg, included) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def add_included_sym(arg, included)
|
|
95
|
+
case arg&.type
|
|
96
|
+
when :sym
|
|
97
|
+
included << arg.children[0]
|
|
98
|
+
when :hash
|
|
99
|
+
arg.children.each { |pair| extract_sym_from_pair(pair, included) }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def extract_sym_from_pair(pair, included)
|
|
104
|
+
return unless pair.type == :pair
|
|
105
|
+
|
|
106
|
+
key = pair.children[0]
|
|
107
|
+
included << key.children[0] if key&.type == :sym
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -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
|
|
@@ -52,6 +52,34 @@ module EagerEye
|
|
|
52
52
|
rescue Parser::SyntaxError
|
|
53
53
|
nil
|
|
54
54
|
end
|
|
55
|
+
|
|
56
|
+
def extract_method_args(node)
|
|
57
|
+
return [] unless node&.type == :send
|
|
58
|
+
|
|
59
|
+
node.children[2..]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def extract_symbols_from_args(args)
|
|
63
|
+
symbols = Set.new
|
|
64
|
+
return symbols if args.empty?
|
|
65
|
+
|
|
66
|
+
args.each do |arg|
|
|
67
|
+
case arg&.type
|
|
68
|
+
when :sym
|
|
69
|
+
symbols.add(arg.children[0])
|
|
70
|
+
when :hash
|
|
71
|
+
extract_symbols_from_hash(arg, symbols)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
symbols
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def extract_symbols_from_hash(hash_node, symbols)
|
|
78
|
+
hash_node.children.each do |pair|
|
|
79
|
+
key = pair.children[0]
|
|
80
|
+
symbols.add(key.children[0]) if key&.type == :sym
|
|
81
|
+
end
|
|
82
|
+
end
|
|
55
83
|
end
|
|
56
84
|
end
|
|
57
85
|
end
|
|
@@ -31,10 +31,11 @@ module EagerEye
|
|
|
31
31
|
:loop_association
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
def detect(ast, file_path)
|
|
34
|
+
def detect(ast, file_path, association_preloads = {})
|
|
35
35
|
return [] unless ast
|
|
36
36
|
|
|
37
37
|
issues = []
|
|
38
|
+
@association_preloads = association_preloads
|
|
38
39
|
build_variable_maps(ast)
|
|
39
40
|
|
|
40
41
|
traverse_ast(ast) do |node|
|
|
@@ -51,6 +52,8 @@ module EagerEye
|
|
|
51
52
|
|
|
52
53
|
included = extract_included_associations(collection_node)
|
|
53
54
|
included.merge(extract_variable_preloads(collection_node))
|
|
55
|
+
model_name = infer_model_name_from_collection(collection_node)
|
|
56
|
+
included.merge(get_association_preloads(model_name))
|
|
54
57
|
|
|
55
58
|
find_association_calls(block_body, block_var, file_path, issues, included)
|
|
56
59
|
end
|
|
@@ -60,6 +63,27 @@ module EagerEye
|
|
|
60
63
|
|
|
61
64
|
private
|
|
62
65
|
|
|
66
|
+
def get_association_preloads(model_name)
|
|
67
|
+
key = "#{model_name}#*"
|
|
68
|
+
preloaded = Set.new
|
|
69
|
+
@association_preloads&.each do |assoc_key, assocs|
|
|
70
|
+
preloaded.merge(assocs) if assoc_key.start_with?(key)
|
|
71
|
+
end
|
|
72
|
+
preloaded
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def infer_model_name_from_collection(node)
|
|
76
|
+
# Infer model name from collection (posts -> Post, users -> User)
|
|
77
|
+
return nil unless node&.type == :send
|
|
78
|
+
|
|
79
|
+
# Handle: Model.includes, @var.method calls
|
|
80
|
+
receiver = node.children[0]
|
|
81
|
+
case receiver&.type
|
|
82
|
+
when :const
|
|
83
|
+
receiver.children[1].to_s
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
63
87
|
def iteration_block?(node)
|
|
64
88
|
return false unless node.type == :block
|
|
65
89
|
|
|
@@ -71,14 +95,9 @@ module EagerEye
|
|
|
71
95
|
end
|
|
72
96
|
|
|
73
97
|
def extract_block_variable(block_node)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
first_arg = args_node.children[0]
|
|
79
|
-
return nil unless first_arg&.type == :arg
|
|
80
|
-
|
|
81
|
-
first_arg.children[0]
|
|
98
|
+
args = block_node&.children&.fetch(1, nil)
|
|
99
|
+
first_child = args&.children&.first
|
|
100
|
+
first_child&.type == :arg ? first_child.children[0] : nil
|
|
82
101
|
end
|
|
83
102
|
|
|
84
103
|
def extract_included_associations(collection_node)
|
|
@@ -101,19 +120,21 @@ module EagerEye
|
|
|
101
120
|
@variable_preloads = {}
|
|
102
121
|
@single_record_variables = Set.new
|
|
103
122
|
|
|
104
|
-
traverse_ast(ast)
|
|
105
|
-
|
|
123
|
+
traverse_ast(ast) { |node| process_variable_assignment(node) }
|
|
124
|
+
end
|
|
106
125
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
value_node = node.children[1]
|
|
110
|
-
next unless value_node
|
|
126
|
+
def process_variable_assignment(node)
|
|
127
|
+
return unless %i[lvasgn ivasgn].include?(node.type)
|
|
111
128
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
129
|
+
var_type = node.type == :lvasgn ? :lvar : :ivar
|
|
130
|
+
var_name = node.children[0]
|
|
131
|
+
value_node = node.children[1]
|
|
132
|
+
return unless value_node
|
|
133
|
+
|
|
134
|
+
key = [var_type, var_name]
|
|
135
|
+
preloaded = extract_included_associations(value_node)
|
|
136
|
+
@variable_preloads[key] = preloaded unless preloaded.empty?
|
|
137
|
+
@single_record_variables.add(key) if single_record_query?(value_node)
|
|
117
138
|
end
|
|
118
139
|
|
|
119
140
|
def extract_variable_preloads(node)
|
|
@@ -130,11 +151,18 @@ module EagerEye
|
|
|
130
151
|
end
|
|
131
152
|
|
|
132
153
|
def single_record_query?(node)
|
|
154
|
+
last_send = find_last_send_method(node)
|
|
155
|
+
last_send && SINGLE_RECORD_METHODS.include?(last_send)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def find_last_send_method(node)
|
|
133
159
|
current = node
|
|
134
|
-
while current&.type == :send && !
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
160
|
+
current = current.children[0] while current&.type == :send && !single_record_method?(current)
|
|
161
|
+
current&.type == :send ? current.children[1] : nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def single_record_method?(node)
|
|
165
|
+
SINGLE_RECORD_METHODS.include?(node.children[1])
|
|
138
166
|
end
|
|
139
167
|
|
|
140
168
|
def single_record_iteration?(node)
|
|
@@ -145,67 +173,50 @@ module EagerEye
|
|
|
145
173
|
end
|
|
146
174
|
|
|
147
175
|
def extract_includes_from_method(method_node, included_set)
|
|
148
|
-
args = method_node
|
|
149
|
-
args
|
|
150
|
-
case arg&.type
|
|
151
|
-
when :sym
|
|
152
|
-
# includes(:product)
|
|
153
|
-
included_set.add(arg.children[0])
|
|
154
|
-
when :hash
|
|
155
|
-
# includes(product: :manufacturer)
|
|
156
|
-
extract_from_hash(arg, included_set)
|
|
157
|
-
end
|
|
158
|
-
end
|
|
176
|
+
args = extract_method_args(method_node)
|
|
177
|
+
included_set.merge(extract_symbols_from_args(args))
|
|
159
178
|
end
|
|
160
179
|
|
|
161
180
|
def extract_from_hash(hash_node, included_set)
|
|
162
|
-
hash_node
|
|
163
|
-
key = pair.children[0]
|
|
164
|
-
included_set.add(key.children[0]) if key&.type == :sym
|
|
165
|
-
end
|
|
181
|
+
extract_symbols_from_hash(hash_node, included_set)
|
|
166
182
|
end
|
|
167
183
|
|
|
168
184
|
def find_association_calls(node, block_var, file_path, issues, included_associations = Set.new)
|
|
169
|
-
|
|
170
|
-
|
|
185
|
+
reported = Set.new
|
|
171
186
|
traverse_ast(node) do |child|
|
|
172
|
-
next unless child
|
|
187
|
+
next unless should_report_issue?(child, block_var, reported, included_associations)
|
|
173
188
|
|
|
174
|
-
|
|
175
|
-
method_name = child.children[1]
|
|
176
|
-
|
|
177
|
-
# Only detect direct calls on block variable (post.author, not post.author.name)
|
|
178
|
-
next unless direct_call_on_block_var?(receiver, block_var)
|
|
179
|
-
next unless likely_association?(method_name)
|
|
180
|
-
|
|
181
|
-
# Skip if association is already included
|
|
182
|
-
next if included_associations.include?(method_name)
|
|
183
|
-
|
|
184
|
-
# Avoid duplicate reports for same association on same line
|
|
185
|
-
report_key = "#{child.loc.line}:#{method_name}"
|
|
186
|
-
next if reported_associations.include?(report_key)
|
|
187
|
-
|
|
188
|
-
reported_associations << report_key
|
|
189
|
-
|
|
190
|
-
issues << create_issue(
|
|
191
|
-
file_path: file_path,
|
|
192
|
-
line_number: child.loc.line,
|
|
193
|
-
message: "Potential N+1 query: `#{block_var}.#{method_name}` called inside iteration",
|
|
194
|
-
suggestion: "Consider using `includes(:#{method_name})` on the collection before iterating"
|
|
195
|
-
)
|
|
189
|
+
add_n_plus_one_issue(child, block_var, file_path, issues, reported)
|
|
196
190
|
end
|
|
197
191
|
end
|
|
198
192
|
|
|
199
|
-
def
|
|
200
|
-
return false unless
|
|
193
|
+
def should_report_issue?(child, block_var, reported, included)
|
|
194
|
+
return false unless child.type == :send
|
|
195
|
+
|
|
196
|
+
receiver = child.children[0]
|
|
197
|
+
method = child.children[1]
|
|
198
|
+
return false unless receiver&.type == :lvar && receiver.children[0] == block_var
|
|
199
|
+
return false if excluded?(method, included)
|
|
201
200
|
|
|
202
|
-
|
|
201
|
+
key = "#{child.loc.line}:#{method}"
|
|
202
|
+
!reported.include?(key) && reported.add(key)
|
|
203
203
|
end
|
|
204
204
|
|
|
205
|
-
def
|
|
206
|
-
|
|
205
|
+
def excluded?(method, included)
|
|
206
|
+
EXCLUDED_METHODS.include?(method) ||
|
|
207
|
+
!ASSOCIATION_NAMES.include?(method.to_s) ||
|
|
208
|
+
included.include?(method)
|
|
209
|
+
end
|
|
207
210
|
|
|
208
|
-
|
|
211
|
+
def add_n_plus_one_issue(node, block_var, file_path, issues, reported)
|
|
212
|
+
method = node.children[1]
|
|
213
|
+
reported << "#{node.loc.line}:#{method}"
|
|
214
|
+
issues << create_issue(
|
|
215
|
+
file_path: file_path,
|
|
216
|
+
line_number: node.loc.line,
|
|
217
|
+
message: "Potential N+1 query: `#{block_var}.#{method}` called inside loop",
|
|
218
|
+
suggestion: "Use `includes(:#{method})` before iterating"
|
|
219
|
+
)
|
|
209
220
|
end
|
|
210
221
|
end
|
|
211
222
|
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
|
|
|
@@ -139,8 +149,19 @@ module EagerEye
|
|
|
139
149
|
@issues << create_issue(
|
|
140
150
|
file_path: @file_path,
|
|
141
151
|
line_number: node.loc.line,
|
|
142
|
-
message: "Using plucked
|
|
143
|
-
suggestion: "Use `.select(:id)` subquery: `Model.where(col: OtherModel.
|
|
152
|
+
message: "Using plucked array in `where` causes two queries and memory overhead",
|
|
153
|
+
suggestion: "Use `.select(:id)` subquery instead: `Model.where(col: OtherModel.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)` loads entire table into memory - highly inefficient",
|
|
163
|
+
suggestion: "Use `.select(:id)` subquery: `Model.where(col: OtherModel.select(:id))`",
|
|
164
|
+
severity: :error
|
|
144
165
|
)
|
|
145
166
|
end
|
|
146
167
|
end
|
data/lib/eager_eye/version.rb
CHANGED
data/lib/eager_eye.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "eager_eye/version"
|
|
4
4
|
require_relative "eager_eye/configuration"
|
|
5
5
|
require_relative "eager_eye/issue"
|
|
6
|
+
require_relative "eager_eye/association_parser"
|
|
6
7
|
require_relative "eager_eye/detectors/base"
|
|
7
8
|
require_relative "eager_eye/detectors/loop_association"
|
|
8
9
|
require_relative "eager_eye/detectors/serializer_nesting"
|
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.1.0
|
|
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-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ast
|
|
@@ -59,6 +59,7 @@ files:
|
|
|
59
59
|
- exe/eager_eye
|
|
60
60
|
- lib/eager_eye.rb
|
|
61
61
|
- lib/eager_eye/analyzer.rb
|
|
62
|
+
- lib/eager_eye/association_parser.rb
|
|
62
63
|
- lib/eager_eye/auto_fixer.rb
|
|
63
64
|
- lib/eager_eye/cli.rb
|
|
64
65
|
- lib/eager_eye/comment_parser.rb
|