eager_eye 1.2.9 → 1.2.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 +12 -0
- data/README.md +2 -1
- data/lib/eager_eye/analyzer.rb +11 -5
- data/lib/eager_eye/detectors/custom_method_query.rb +28 -1
- data/lib/eager_eye/detectors/decorator_n_plus_one.rb +22 -8
- data/lib/eager_eye/detectors/loop_association.rb +31 -10
- data/lib/eager_eye/detectors/serializer_nesting.rb +23 -9
- data/lib/eager_eye/method_query_parser.rb +61 -0
- data/lib/eager_eye/version.rb +1 -1
- data/lib/eager_eye.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 428a53d534da23f40ea61c600afe71dab1b3463eab4a0640a78502eb35dd2ebc
|
|
4
|
+
data.tar.gz: 004edfb6e51aa7e4708ff1731d1205fa336722995f440ac438e49535be7934b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e1e172cc60a07b7f031be8236b626c4b1fc5d081ef201d952d6e366ffddeddbcc102332cd281714529a0f51b3a35df5195a567b32d596ccf9af97aff93eb28a
|
|
7
|
+
data.tar.gz: c52ac7fe9047f9cdb3cf8ba040cea3faf0f910f50e6f36561d20fc2728da1a41c0e442efbc7af4511e68744071c005fd1dd328cf5cac68bb4807e5220bfcbc51
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.2.10] - 2026-03-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Cross-File Analysis** - Model methods containing queries are now detected when called from other files
|
|
15
|
+
- New `MethodQueryParser` scans model files for methods that execute queries (`.where`, `.find_by`, `.count`, `.pluck`, etc.)
|
|
16
|
+
- `LoopAssociation` detects cross-file query methods called inside loops (e.g. `user.active_orders` in a controller)
|
|
17
|
+
- `CustomMethodQuery` detects model query methods called inside iterations
|
|
18
|
+
- `SerializerNesting` detects model query methods called in serializer attribute blocks
|
|
19
|
+
- `DecoratorNPlusOne` detects model query methods called in decorator methods
|
|
20
|
+
- Two-pass architecture: first pass collects method signatures, second pass detects cross-file N+1 patterns
|
|
21
|
+
|
|
10
22
|
## [1.2.9] - 2026-03-13
|
|
11
23
|
|
|
12
24
|
### 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.2.
|
|
13
|
+
<a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.2.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>
|
|
@@ -739,6 +739,7 @@ EagerEye uses static analysis, which means:
|
|
|
739
739
|
- **No runtime context** - Cannot know if associations are already eager loaded elsewhere
|
|
740
740
|
- **Heuristic-based** - Uses naming conventions to identify associations (may have false positives)
|
|
741
741
|
- **Ruby code only** - Does not analyze SQL queries or ActiveRecord internals
|
|
742
|
+
- **Cross-file scope** - Cross-file analysis is limited to model-defined methods; controller-to-view or service-to-service patterns are not yet tracked
|
|
742
743
|
|
|
743
744
|
For best results, use EagerEye alongside runtime tools like Bullet for comprehensive N+1 detection.
|
|
744
745
|
|
data/lib/eager_eye/analyzer.rb
CHANGED
|
@@ -19,23 +19,25 @@ module EagerEye
|
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
21
|
DETECTOR_EXTRA_ARGS = {
|
|
22
|
-
Detectors::LoopAssociation => %i[association_preloads association_names],
|
|
23
|
-
Detectors::SerializerNesting => %i[association_names],
|
|
22
|
+
Detectors::LoopAssociation => %i[association_preloads association_names method_queries],
|
|
23
|
+
Detectors::SerializerNesting => %i[association_names method_queries],
|
|
24
24
|
Detectors::MissingCounterCache => %i[association_names],
|
|
25
|
-
Detectors::DecoratorNPlusOne => %i[association_names],
|
|
25
|
+
Detectors::DecoratorNPlusOne => %i[association_names method_queries],
|
|
26
|
+
Detectors::CustomMethodQuery => %i[method_queries],
|
|
26
27
|
Detectors::DelegationNPlusOne => %i[delegation_maps],
|
|
27
28
|
Detectors::ScopeChainNPlusOne => %i[scope_maps],
|
|
28
29
|
Detectors::ValidationNPlusOne => %i[uniqueness_models]
|
|
29
30
|
}.freeze
|
|
30
31
|
|
|
31
|
-
attr_reader :paths, :issues, :association_preloads, :association_names, :
|
|
32
|
-
:uniqueness_models
|
|
32
|
+
attr_reader :paths, :issues, :association_preloads, :association_names, :method_queries, :delegation_maps,
|
|
33
|
+
:scope_maps, :uniqueness_models
|
|
33
34
|
|
|
34
35
|
def initialize(paths: nil)
|
|
35
36
|
@paths = Array(paths || EagerEye.configuration.app_path)
|
|
36
37
|
@issues = []
|
|
37
38
|
@association_preloads = {}
|
|
38
39
|
@association_names = Set.new
|
|
40
|
+
@method_queries = {}
|
|
39
41
|
@delegation_maps = {}
|
|
40
42
|
@scope_maps = {}
|
|
41
43
|
@uniqueness_models = Set.new
|
|
@@ -73,6 +75,10 @@ module EagerEye
|
|
|
73
75
|
validation_parser = ValidationParser.new
|
|
74
76
|
validation_parser.parse_model(ast, model_name)
|
|
75
77
|
@uniqueness_models.merge(validation_parser.uniqueness_models)
|
|
78
|
+
|
|
79
|
+
method_query_parser = MethodQueryParser.new
|
|
80
|
+
method_query_parser.parse_model(ast, model_name)
|
|
81
|
+
@method_queries.merge!(method_query_parser.method_queries)
|
|
76
82
|
rescue Errno::ENOENT, Errno::EACCES
|
|
77
83
|
next
|
|
78
84
|
end
|
|
@@ -15,14 +15,16 @@ module EagerEye
|
|
|
15
15
|
:custom_method_query
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def detect(ast, file_path)
|
|
18
|
+
def detect(ast, file_path, method_queries = {})
|
|
19
19
|
return [] unless ast
|
|
20
20
|
|
|
21
21
|
@issues = []
|
|
22
22
|
@file_path = file_path
|
|
23
|
+
@method_queries = method_queries
|
|
23
24
|
|
|
24
25
|
find_iteration_blocks(ast) do |block_body, block_var, collection, definitions|
|
|
25
26
|
check_block_for_query_methods(block_body, block_var, collection_is_array?(collection, definitions))
|
|
27
|
+
check_block_for_model_query_methods(block_body, block_var)
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
@issues
|
|
@@ -112,6 +114,31 @@ module EagerEye
|
|
|
112
114
|
node.is_a?(Parser::AST::Node) && node.type == :send && QUERY_METHODS.include?(node.children[1])
|
|
113
115
|
end
|
|
114
116
|
|
|
117
|
+
def check_block_for_model_query_methods(node, block_var)
|
|
118
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
119
|
+
|
|
120
|
+
if model_query_call?(node, block_var)
|
|
121
|
+
method = node.children[1]
|
|
122
|
+
@issues << create_issue(
|
|
123
|
+
file_path: @file_path,
|
|
124
|
+
line_number: node.loc.line,
|
|
125
|
+
message: "Model method `.#{method}` contains a query and is called inside iteration",
|
|
126
|
+
suggestion: "This method executes a query on each iteration. Preload data or move the query outside."
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
node.children.each { |child| check_block_for_model_query_methods(child, block_var) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def model_query_call?(node, block_var)
|
|
133
|
+
return false unless node.type == :send
|
|
134
|
+
|
|
135
|
+
receiver = node.children[0]
|
|
136
|
+
method = node.children[1]
|
|
137
|
+
return false unless receiver&.type == :lvar && receiver.children[0] == block_var
|
|
138
|
+
|
|
139
|
+
@method_queries&.any? { |_model, methods| methods.include?(method) }
|
|
140
|
+
end
|
|
141
|
+
|
|
115
142
|
def add_issue(node)
|
|
116
143
|
chain = reconstruct_chain(node.children[0])
|
|
117
144
|
@issues << create_issue(
|
|
@@ -14,10 +14,11 @@ module EagerEye
|
|
|
14
14
|
:decorator_n_plus_one
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def detect(ast, file_path, association_names = Set.new)
|
|
17
|
+
def detect(ast, file_path, association_names = Set.new, method_queries = {})
|
|
18
18
|
return [] unless ast
|
|
19
19
|
|
|
20
20
|
@dynamic_associations = association_names
|
|
21
|
+
@method_queries = method_queries
|
|
21
22
|
issues = []
|
|
22
23
|
traverse_ast(ast) do |node|
|
|
23
24
|
next unless node.type == :class && decorator_class?(node)
|
|
@@ -65,21 +66,34 @@ module EagerEye
|
|
|
65
66
|
|
|
66
67
|
receiver = node.children[0]
|
|
67
68
|
method_name = node.children[1]
|
|
68
|
-
next unless object_reference?(receiver)
|
|
69
|
+
next unless object_reference?(receiver)
|
|
69
70
|
|
|
70
71
|
ref = receiver.children[1]
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
if likely_association?(method_name)
|
|
73
|
+
issues << create_issue(
|
|
74
|
+
file_path: file_path,
|
|
75
|
+
line_number: node.loc.line,
|
|
76
|
+
message: "N+1 in decorator: `#{ref}.#{method_name}` loads association on each decorated object",
|
|
77
|
+
suggestion: "Eager load :#{method_name} in the controller before decorating the collection"
|
|
78
|
+
)
|
|
79
|
+
elsif model_query_method?(method_name)
|
|
80
|
+
issues << create_issue(
|
|
81
|
+
file_path: file_path,
|
|
82
|
+
line_number: node.loc.line,
|
|
83
|
+
message: "N+1 in decorator: `#{ref}.#{method_name}` calls a query method defined in the model",
|
|
84
|
+
suggestion: "Preload the data in the controller before decorating the collection"
|
|
85
|
+
)
|
|
86
|
+
end
|
|
77
87
|
end
|
|
78
88
|
end
|
|
79
89
|
|
|
80
90
|
def object_reference?(node)
|
|
81
91
|
node&.type == :send && node.children[0].nil? && OBJECT_REFS.include?(node.children[1])
|
|
82
92
|
end
|
|
93
|
+
|
|
94
|
+
def model_query_method?(method_name)
|
|
95
|
+
@method_queries&.any? { |_model, methods| methods.include?(method_name) }
|
|
96
|
+
end
|
|
83
97
|
end
|
|
84
98
|
end
|
|
85
99
|
end
|
|
@@ -30,12 +30,13 @@ module EagerEye
|
|
|
30
30
|
:loop_association
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def detect(ast, file_path, association_preloads = {}, association_names = Set.new)
|
|
33
|
+
def detect(ast, file_path, association_preloads = {}, association_names = Set.new, method_queries = {})
|
|
34
34
|
return [] unless ast
|
|
35
35
|
|
|
36
36
|
issues = []
|
|
37
37
|
@association_preloads = association_preloads
|
|
38
38
|
@dynamic_associations = association_names
|
|
39
|
+
@method_queries = method_queries
|
|
39
40
|
build_variable_maps(ast)
|
|
40
41
|
|
|
41
42
|
traverse_ast(ast) do |node|
|
|
@@ -157,15 +158,23 @@ module EagerEye
|
|
|
157
158
|
def find_association_calls(node, block_var, file_path, issues, included_associations = Set.new)
|
|
158
159
|
reported = Set.new
|
|
159
160
|
traverse_ast(node) do |child|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
)
|
|
161
|
+
if reportable_association_call?(child, block_var, reported, included_associations)
|
|
162
|
+
method = child.children[1]
|
|
163
|
+
issues << create_issue(
|
|
164
|
+
file_path: file_path,
|
|
165
|
+
line_number: child.loc.line,
|
|
166
|
+
message: "Potential N+1 query: `#{block_var}.#{method}` called inside loop",
|
|
167
|
+
suggestion: "Use `includes(:#{method})` before iterating"
|
|
168
|
+
)
|
|
169
|
+
elsif reportable_method_query_call?(child, block_var, reported)
|
|
170
|
+
method = child.children[1]
|
|
171
|
+
issues << create_issue(
|
|
172
|
+
file_path: file_path,
|
|
173
|
+
line_number: child.loc.line,
|
|
174
|
+
message: "Potential N+1 query: `#{block_var}.#{method}` calls a query method defined in the model",
|
|
175
|
+
suggestion: "Preload the data or restructure to avoid per-record queries"
|
|
176
|
+
)
|
|
177
|
+
end
|
|
169
178
|
end
|
|
170
179
|
end
|
|
171
180
|
|
|
@@ -189,6 +198,18 @@ module EagerEye
|
|
|
189
198
|
def known_association?(method)
|
|
190
199
|
ASSOCIATION_NAMES.include?(method.to_s) || @dynamic_associations.include?(method)
|
|
191
200
|
end
|
|
201
|
+
|
|
202
|
+
def reportable_method_query_call?(node, block_var, reported)
|
|
203
|
+
return false unless block_var_send?(node, block_var)
|
|
204
|
+
return false if EXCLUDED_METHODS.include?(node.children[1])
|
|
205
|
+
return false unless @method_queries&.any? { |_, ms| ms.include?(node.children[1]) }
|
|
206
|
+
|
|
207
|
+
reported.add?("#{node.loc.line}:#{node.children[1]}")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def block_var_send?(node, block_var)
|
|
211
|
+
node.type == :send && node.children[0]&.type == :lvar && node.children[0].children[0] == block_var
|
|
212
|
+
end
|
|
192
213
|
end
|
|
193
214
|
end
|
|
194
215
|
end
|
|
@@ -15,10 +15,11 @@ module EagerEye
|
|
|
15
15
|
:serializer_nesting
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def detect(ast, file_path, association_names = Set.new)
|
|
18
|
+
def detect(ast, file_path, association_names = Set.new, method_queries = {})
|
|
19
19
|
return [] unless ast
|
|
20
20
|
|
|
21
21
|
@dynamic_associations = association_names
|
|
22
|
+
@method_queries = method_queries
|
|
22
23
|
issues = []
|
|
23
24
|
traverse_ast(ast) do |node|
|
|
24
25
|
next unless node.type == :class && serializer_class?(node)
|
|
@@ -86,14 +87,23 @@ module EagerEye
|
|
|
86
87
|
|
|
87
88
|
receiver = node.children[0]
|
|
88
89
|
method_name = node.children[1]
|
|
89
|
-
next unless object_reference?(receiver)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
next unless object_reference?(receiver)
|
|
91
|
+
|
|
92
|
+
if likely_association?(method_name)
|
|
93
|
+
issues << create_issue(
|
|
94
|
+
file_path: file_path,
|
|
95
|
+
line_number: node.loc.line,
|
|
96
|
+
message: "Nested association `#{receiver_name(receiver)}.#{method_name}` in serializer attribute",
|
|
97
|
+
suggestion: "Eager load :#{method_name} in controller or use association serializer"
|
|
98
|
+
)
|
|
99
|
+
elsif model_query_method?(method_name)
|
|
100
|
+
issues << create_issue(
|
|
101
|
+
file_path: file_path,
|
|
102
|
+
line_number: node.loc.line,
|
|
103
|
+
message: "Model method `#{receiver_name(receiver)}.#{method_name}` contains a query in serializer",
|
|
104
|
+
suggestion: "This method executes a query per serialized object. Preload or cache the data."
|
|
105
|
+
)
|
|
106
|
+
end
|
|
97
107
|
end
|
|
98
108
|
end
|
|
99
109
|
|
|
@@ -114,6 +124,10 @@ module EagerEye
|
|
|
114
124
|
else "object"
|
|
115
125
|
end
|
|
116
126
|
end
|
|
127
|
+
|
|
128
|
+
def model_query_method?(method_name)
|
|
129
|
+
@method_queries&.any? { |_model, methods| methods.include?(method_name) }
|
|
130
|
+
end
|
|
117
131
|
end
|
|
118
132
|
end
|
|
119
133
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
class MethodQueryParser
|
|
5
|
+
QUERY_METHODS = %i[where find find_by find_by! exists? any? none? many? one?
|
|
6
|
+
count sum average minimum maximum
|
|
7
|
+
pluck ids select
|
|
8
|
+
update_all delete_all destroy_all
|
|
9
|
+
create create! save save!
|
|
10
|
+
first last take].freeze
|
|
11
|
+
|
|
12
|
+
ASSOCIATION_METHODS = %i[has_many has_one belongs_to has_and_belongs_to_many].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :method_queries
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@method_queries = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def parse_model(ast, model_name)
|
|
21
|
+
return unless ast
|
|
22
|
+
|
|
23
|
+
traverse(ast, model_name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def traverse(node, model_name)
|
|
29
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
30
|
+
|
|
31
|
+
check_method(node, model_name)
|
|
32
|
+
node.children.each { |child| traverse(child, model_name) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def check_method(node, model_name)
|
|
36
|
+
return unless instance_method_def?(node)
|
|
37
|
+
|
|
38
|
+
method_name = node.children[0]
|
|
39
|
+
body = node.children[2]
|
|
40
|
+
return unless body && contains_query?(body)
|
|
41
|
+
|
|
42
|
+
@method_queries[model_name] ||= Set.new
|
|
43
|
+
@method_queries[model_name] << method_name
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def instance_method_def?(node)
|
|
47
|
+
node.type == :def
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def contains_query?(node)
|
|
51
|
+
return false unless node.is_a?(Parser::AST::Node)
|
|
52
|
+
return true if query_call?(node)
|
|
53
|
+
|
|
54
|
+
node.children.any? { |child| contains_query?(child) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def query_call?(node)
|
|
58
|
+
node.type == :send && QUERY_METHODS.include?(node.children[1])
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/eager_eye/version.rb
CHANGED
data/lib/eager_eye.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "eager_eye/association_parser"
|
|
|
8
8
|
require_relative "eager_eye/delegation_parser"
|
|
9
9
|
require_relative "eager_eye/scope_parser"
|
|
10
10
|
require_relative "eager_eye/validation_parser"
|
|
11
|
+
require_relative "eager_eye/method_query_parser"
|
|
11
12
|
require_relative "eager_eye/detectors/base"
|
|
12
13
|
require_relative "eager_eye/detectors/loop_association"
|
|
13
14
|
require_relative "eager_eye/detectors/serializer_nesting"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: eager_eye
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.2.
|
|
4
|
+
version: 1.2.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- hamzagedikkaya
|
|
@@ -84,6 +84,7 @@ files:
|
|
|
84
84
|
- lib/eager_eye/fixers/pluck_to_select.rb
|
|
85
85
|
- lib/eager_eye/generators/install_generator.rb
|
|
86
86
|
- lib/eager_eye/issue.rb
|
|
87
|
+
- lib/eager_eye/method_query_parser.rb
|
|
87
88
|
- lib/eager_eye/railtie.rb
|
|
88
89
|
- lib/eager_eye/reporters/base.rb
|
|
89
90
|
- lib/eager_eye/reporters/console.rb
|