eager_eye 1.0.10 → 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 +13 -11
- data/CONTRIBUTING.md +1 -1
- data/README.md +20 -29
- data/lib/eager_eye/analyzer.rb +34 -6
- data/lib/eager_eye/association_parser.rb +110 -0
- 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 +4 -4
- 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,21 +7,23 @@ 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
|
+
## [1.1.0] - 2025-12-28
|
|
11
11
|
|
|
12
|
-
###
|
|
12
|
+
### Added
|
|
13
13
|
|
|
14
|
-
- **
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
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
18
|
|
|
19
|
-
|
|
19
|
+
## [1.0.10] - 2025-12-27
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
20
22
|
|
|
21
|
-
- **
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
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
|
|
25
27
|
|
|
26
28
|
## [1.0.9] - 2025-12-26
|
|
27
29
|
|
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>
|
|
@@ -153,6 +153,17 @@ posts.each { |post| post.comments.size } # No warning
|
|
|
153
153
|
@user.posts.each do |post|
|
|
154
154
|
post.comments # No warning - single user, no N+1
|
|
155
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
|
|
156
167
|
```
|
|
157
168
|
|
|
158
169
|
### 2. Serializer Nesting (N+1 in serializers)
|
|
@@ -313,40 +324,20 @@ Detects when `.pluck(:id)` or `.map(&:id)` results are used in `where` clauses i
|
|
|
313
324
|
|
|
314
325
|
```ruby
|
|
315
326
|
# Bad - Two queries + memory overhead
|
|
316
|
-
user_ids = User.active.pluck(:id)
|
|
317
|
-
Post.where(user_id: user_ids)
|
|
318
|
-
# Also holds potentially thousands of IDs in memory
|
|
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 (...)
|
|
327
|
+
user_ids = User.active.pluck(:id)
|
|
328
|
+
Post.where(user_id: user_ids) # ⚠️ Warning
|
|
323
329
|
|
|
324
|
-
#
|
|
325
|
-
user_ids =
|
|
330
|
+
# Worse - Loads entire table! 🔴 Error
|
|
331
|
+
user_ids = User.all.pluck(:id)
|
|
326
332
|
Post.where(user_id: user_ids)
|
|
327
333
|
|
|
328
|
-
# Good - Single subquery
|
|
334
|
+
# Good - Single subquery
|
|
329
335
|
Post.where(user_id: User.active.select(:id))
|
|
330
|
-
# Single query: SELECT * FROM posts WHERE user_id IN (SELECT id FROM users WHERE active = true)
|
|
331
336
|
```
|
|
332
337
|
|
|
333
|
-
**Severity
|
|
334
|
-
|
|
335
|
-
-
|
|
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
|
-
|
|
343
|
-
**Performance comparison with 10,000 users:**
|
|
344
|
-
|
|
345
|
-
| Approach | Queries | Memory | Time |
|
|
346
|
-
|----------|---------|--------|------|
|
|
347
|
-
| `pluck` + `where` | 2 | ~80KB for IDs | ~45ms |
|
|
348
|
-
| `.all.pluck` + `where` | 2 | ~40KB+ for all IDs | ~100ms+ |
|
|
349
|
-
| `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)
|
|
350
341
|
|
|
351
342
|
## Inline Suppression
|
|
352
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
|
|
@@ -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
|
|
@@ -149,8 +149,8 @@ module EagerEye
|
|
|
149
149
|
@issues << create_issue(
|
|
150
150
|
file_path: @file_path,
|
|
151
151
|
line_number: node.loc.line,
|
|
152
|
-
message: "Using plucked
|
|
153
|
-
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
154
|
severity: :warning
|
|
155
155
|
)
|
|
156
156
|
end
|
|
@@ -159,8 +159,8 @@ module EagerEye
|
|
|
159
159
|
@issues << create_issue(
|
|
160
160
|
file_path: @file_path,
|
|
161
161
|
line_number: node.loc.line,
|
|
162
|
-
message: "Using `.all.pluck(:id)`
|
|
163
|
-
suggestion: "Use `.select(:id)` subquery
|
|
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
164
|
severity: :error
|
|
165
165
|
)
|
|
166
166
|
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
|