eager_eye 1.2.8 → 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 +22 -0
- data/README.md +2 -1
- data/lib/eager_eye/analyzer.rb +22 -7
- data/lib/eager_eye/association_parser.rb +4 -1
- data/lib/eager_eye/detectors/concerns/class_inspector.rb +1 -1
- data/lib/eager_eye/detectors/custom_method_query.rb +28 -1
- data/lib/eager_eye/detectors/decorator_n_plus_one.rb +23 -8
- data/lib/eager_eye/detectors/loop_association.rb +37 -11
- data/lib/eager_eye/detectors/missing_counter_cache.rb +6 -2
- data/lib/eager_eye/detectors/serializer_nesting.rb +24 -9
- data/lib/eager_eye/detectors/validation_n_plus_one.rb +118 -0
- data/lib/eager_eye/method_query_parser.rb +61 -0
- data/lib/eager_eye/validation_parser.rb +52 -0
- data/lib/eager_eye/version.rb +1 -1
- data/lib/eager_eye.rb +1 -0
- metadata +5 -2
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,28 @@ 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
|
+
|
|
22
|
+
## [1.2.9] - 2026-03-13
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **Dynamic Association Detection** - Association names are now automatically parsed from model files
|
|
27
|
+
- `has_many`, `has_one`, `belongs_to`, `has_and_belongs_to_many` declarations are collected
|
|
28
|
+
- Custom associations like `:enrollments`, `:subscriptions`, `:invoices` are now detected
|
|
29
|
+
- Dynamic names supplement (not replace) the hardcoded fallback lists
|
|
30
|
+
- Benefits `LoopAssociation`, `SerializerNesting`, `DecoratorNPlusOne`, and `MissingCounterCache`
|
|
31
|
+
|
|
10
32
|
## [1.2.8] - 2026-03-10
|
|
11
33
|
|
|
12
34
|
### 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.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
|
@@ -18,12 +18,26 @@ module EagerEye
|
|
|
18
18
|
validation_n_plus_one: Detectors::ValidationNPlusOne
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
DETECTOR_EXTRA_ARGS = {
|
|
22
|
+
Detectors::LoopAssociation => %i[association_preloads association_names method_queries],
|
|
23
|
+
Detectors::SerializerNesting => %i[association_names method_queries],
|
|
24
|
+
Detectors::MissingCounterCache => %i[association_names],
|
|
25
|
+
Detectors::DecoratorNPlusOne => %i[association_names method_queries],
|
|
26
|
+
Detectors::CustomMethodQuery => %i[method_queries],
|
|
27
|
+
Detectors::DelegationNPlusOne => %i[delegation_maps],
|
|
28
|
+
Detectors::ScopeChainNPlusOne => %i[scope_maps],
|
|
29
|
+
Detectors::ValidationNPlusOne => %i[uniqueness_models]
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
attr_reader :paths, :issues, :association_preloads, :association_names, :method_queries, :delegation_maps,
|
|
33
|
+
:scope_maps, :uniqueness_models
|
|
22
34
|
|
|
23
35
|
def initialize(paths: nil)
|
|
24
36
|
@paths = Array(paths || EagerEye.configuration.app_path)
|
|
25
37
|
@issues = []
|
|
26
38
|
@association_preloads = {}
|
|
39
|
+
@association_names = Set.new
|
|
40
|
+
@method_queries = {}
|
|
27
41
|
@delegation_maps = {}
|
|
28
42
|
@scope_maps = {}
|
|
29
43
|
@uniqueness_models = Set.new
|
|
@@ -48,6 +62,7 @@ module EagerEye
|
|
|
48
62
|
assoc_parser = AssociationParser.new
|
|
49
63
|
assoc_parser.parse_model(ast, model_name)
|
|
50
64
|
@association_preloads.merge!(assoc_parser.preloaded_associations)
|
|
65
|
+
@association_names.merge(assoc_parser.association_names)
|
|
51
66
|
|
|
52
67
|
deleg_parser = DelegationParser.new
|
|
53
68
|
deleg_parser.parse_model(ast, model_name)
|
|
@@ -60,6 +75,10 @@ module EagerEye
|
|
|
60
75
|
validation_parser = ValidationParser.new
|
|
61
76
|
validation_parser.parse_model(ast, model_name)
|
|
62
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)
|
|
63
82
|
rescue Errno::ENOENT, Errno::EACCES
|
|
64
83
|
next
|
|
65
84
|
end
|
|
@@ -117,12 +136,8 @@ module EagerEye
|
|
|
117
136
|
end
|
|
118
137
|
|
|
119
138
|
def detector_args(detector, ast, file_path)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
args << @delegation_maps if detector.is_a?(Detectors::DelegationNPlusOne)
|
|
123
|
-
args << @scope_maps if detector.is_a?(Detectors::ScopeChainNPlusOne)
|
|
124
|
-
args << @uniqueness_models if detector.is_a?(Detectors::ValidationNPlusOne)
|
|
125
|
-
args
|
|
139
|
+
extra = DETECTOR_EXTRA_ARGS.find { |klass, _| detector.is_a?(klass) }&.last || []
|
|
140
|
+
[ast, file_path, *extra.map { |name| instance_variable_get(:"@#{name}") }]
|
|
126
141
|
end
|
|
127
142
|
|
|
128
143
|
def enabled_detectors
|
|
@@ -4,10 +4,11 @@ module EagerEye
|
|
|
4
4
|
class AssociationParser
|
|
5
5
|
ASSOCIATION_METHODS = %i[has_many has_one belongs_to has_and_belongs_to_many].freeze
|
|
6
6
|
|
|
7
|
-
attr_reader :preloaded_associations
|
|
7
|
+
attr_reader :preloaded_associations, :association_names
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
10
10
|
@preloaded_associations = {}
|
|
11
|
+
@association_names = Set.new
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def parse_model(ast, model_name)
|
|
@@ -35,6 +36,8 @@ module EagerEye
|
|
|
35
36
|
association_name = extract_association_name(node)
|
|
36
37
|
return unless association_name
|
|
37
38
|
|
|
39
|
+
@association_names << association_name
|
|
40
|
+
|
|
38
41
|
preloaded = extract_preloaded_associations(node)
|
|
39
42
|
return if preloaded.empty?
|
|
40
43
|
|
|
@@ -35,7 +35,7 @@ module EagerEye
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def likely_association?(method_name)
|
|
38
|
-
HAS_MANY_ASSOCIATIONS.include?(method_name.to_s)
|
|
38
|
+
HAS_MANY_ASSOCIATIONS.include?(method_name.to_s) || @dynamic_associations&.include?(method_name)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def collect_active_storage_lines(body)
|
|
@@ -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,9 +14,11 @@ module EagerEye
|
|
|
14
14
|
:decorator_n_plus_one
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def detect(ast, file_path)
|
|
17
|
+
def detect(ast, file_path, association_names = Set.new, method_queries = {})
|
|
18
18
|
return [] unless ast
|
|
19
19
|
|
|
20
|
+
@dynamic_associations = association_names
|
|
21
|
+
@method_queries = method_queries
|
|
20
22
|
issues = []
|
|
21
23
|
traverse_ast(ast) do |node|
|
|
22
24
|
next unless node.type == :class && decorator_class?(node)
|
|
@@ -64,21 +66,34 @@ module EagerEye
|
|
|
64
66
|
|
|
65
67
|
receiver = node.children[0]
|
|
66
68
|
method_name = node.children[1]
|
|
67
|
-
next unless object_reference?(receiver)
|
|
69
|
+
next unless object_reference?(receiver)
|
|
68
70
|
|
|
69
71
|
ref = receiver.children[1]
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
76
87
|
end
|
|
77
88
|
end
|
|
78
89
|
|
|
79
90
|
def object_reference?(node)
|
|
80
91
|
node&.type == :send && node.children[0].nil? && OBJECT_REFS.include?(node.children[1])
|
|
81
92
|
end
|
|
93
|
+
|
|
94
|
+
def model_query_method?(method_name)
|
|
95
|
+
@method_queries&.any? { |_model, methods| methods.include?(method_name) }
|
|
96
|
+
end
|
|
82
97
|
end
|
|
83
98
|
end
|
|
84
99
|
end
|
|
@@ -30,11 +30,13 @@ module EagerEye
|
|
|
30
30
|
:loop_association
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def detect(ast, file_path, association_preloads = {})
|
|
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
|
+
@dynamic_associations = association_names
|
|
39
|
+
@method_queries = method_queries
|
|
38
40
|
build_variable_maps(ast)
|
|
39
41
|
|
|
40
42
|
traverse_ast(ast) do |node|
|
|
@@ -156,15 +158,23 @@ module EagerEye
|
|
|
156
158
|
def find_association_calls(node, block_var, file_path, issues, included_associations = Set.new)
|
|
157
159
|
reported = Set.new
|
|
158
160
|
traverse_ast(node) do |child|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
)
|
|
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
|
|
168
178
|
end
|
|
169
179
|
end
|
|
170
180
|
|
|
@@ -181,9 +191,25 @@ module EagerEye
|
|
|
181
191
|
|
|
182
192
|
def excluded_method?(method, included)
|
|
183
193
|
EXCLUDED_METHODS.include?(method) ||
|
|
184
|
-
!
|
|
194
|
+
!known_association?(method) ||
|
|
185
195
|
included.include?(method)
|
|
186
196
|
end
|
|
197
|
+
|
|
198
|
+
def known_association?(method)
|
|
199
|
+
ASSOCIATION_NAMES.include?(method.to_s) || @dynamic_associations.include?(method)
|
|
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
|
|
187
213
|
end
|
|
188
214
|
end
|
|
189
215
|
end
|
|
@@ -17,9 +17,10 @@ module EagerEye
|
|
|
17
17
|
:missing_counter_cache
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def detect(ast, file_path)
|
|
20
|
+
def detect(ast, file_path, association_names = Set.new)
|
|
21
21
|
return [] unless ast
|
|
22
22
|
|
|
23
|
+
@dynamic_associations = association_names
|
|
23
24
|
issues = []
|
|
24
25
|
|
|
25
26
|
traverse_ast(ast) do |node|
|
|
@@ -51,7 +52,10 @@ module EagerEye
|
|
|
51
52
|
end
|
|
52
53
|
|
|
53
54
|
def likely_association_receiver?(node)
|
|
54
|
-
node.type == :send
|
|
55
|
+
return false unless node.type == :send
|
|
56
|
+
|
|
57
|
+
method = node.children[1]
|
|
58
|
+
PLURAL_ASSOCIATIONS.include?(method.to_s) || @dynamic_associations&.include?(method)
|
|
55
59
|
end
|
|
56
60
|
|
|
57
61
|
def extract_association_name(node)
|
|
@@ -15,9 +15,11 @@ module EagerEye
|
|
|
15
15
|
:serializer_nesting
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def detect(ast, file_path)
|
|
18
|
+
def detect(ast, file_path, association_names = Set.new, method_queries = {})
|
|
19
19
|
return [] unless ast
|
|
20
20
|
|
|
21
|
+
@dynamic_associations = association_names
|
|
22
|
+
@method_queries = method_queries
|
|
21
23
|
issues = []
|
|
22
24
|
traverse_ast(ast) do |node|
|
|
23
25
|
next unless node.type == :class && serializer_class?(node)
|
|
@@ -85,14 +87,23 @@ module EagerEye
|
|
|
85
87
|
|
|
86
88
|
receiver = node.children[0]
|
|
87
89
|
method_name = node.children[1]
|
|
88
|
-
next unless object_reference?(receiver)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
96
107
|
end
|
|
97
108
|
end
|
|
98
109
|
|
|
@@ -113,6 +124,10 @@ module EagerEye
|
|
|
113
124
|
else "object"
|
|
114
125
|
end
|
|
115
126
|
end
|
|
127
|
+
|
|
128
|
+
def model_query_method?(method_name)
|
|
129
|
+
@method_queries&.any? { |_model, methods| methods.include?(method_name) }
|
|
130
|
+
end
|
|
116
131
|
end
|
|
117
132
|
end
|
|
118
133
|
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
module Detectors
|
|
5
|
+
class ValidationNPlusOne < Base
|
|
6
|
+
ITERATION_METHODS = %i[each map select find_all reject collect detect find_index flat_map
|
|
7
|
+
find_each find_in_batches in_batches].freeze
|
|
8
|
+
CREATE_METHODS = %i[create create!].freeze
|
|
9
|
+
SAVE_METHODS = %i[save save!].freeze
|
|
10
|
+
|
|
11
|
+
def self.detector_name
|
|
12
|
+
:validation_n_plus_one
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def detect(ast, file_path, uniqueness_models = Set.new)
|
|
16
|
+
return [] unless ast
|
|
17
|
+
|
|
18
|
+
@issues = []
|
|
19
|
+
@file_path = file_path
|
|
20
|
+
@uniqueness_models = uniqueness_models
|
|
21
|
+
return [] if @uniqueness_models.empty?
|
|
22
|
+
|
|
23
|
+
find_iteration_blocks(ast)
|
|
24
|
+
@issues
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def find_iteration_blocks(node)
|
|
30
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
31
|
+
|
|
32
|
+
if iteration_block?(node)
|
|
33
|
+
block_body = node.children[2]
|
|
34
|
+
check_block(block_body) if block_body
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
node.children.each { |child| find_iteration_blocks(child) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def iteration_block?(node)
|
|
41
|
+
node.type == :block && node.children[0]&.type == :send &&
|
|
42
|
+
ITERATION_METHODS.include?(node.children[0].children[1])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def check_block(block_body)
|
|
46
|
+
new_model_vars = {}
|
|
47
|
+
collect_model_new_assignments(block_body, new_model_vars)
|
|
48
|
+
scan_for_issues(block_body, new_model_vars)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def collect_model_new_assignments(node, vars)
|
|
52
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
53
|
+
|
|
54
|
+
if node.type == :lvasgn && model_new_call?(node.children[1])
|
|
55
|
+
model_name = const_name(node.children[1].children[0])
|
|
56
|
+
vars[node.children[0]] = model_name
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
node.children.each { |child| collect_model_new_assignments(child, vars) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def scan_for_issues(node, new_model_vars)
|
|
63
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
64
|
+
|
|
65
|
+
if node.type == :send
|
|
66
|
+
check_create_call(node)
|
|
67
|
+
check_save_call(node, new_model_vars)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
node.children.each { |child| scan_for_issues(child, new_model_vars) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def check_create_call(node)
|
|
74
|
+
return unless CREATE_METHODS.include?(node.children[1])
|
|
75
|
+
|
|
76
|
+
receiver = node.children[0]
|
|
77
|
+
return unless receiver&.type == :const
|
|
78
|
+
|
|
79
|
+
model_name = const_name(receiver)
|
|
80
|
+
add_issue(node, model_name, node.children[1]) if @uniqueness_models.include?(model_name)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def check_save_call(node, new_model_vars)
|
|
84
|
+
return unless SAVE_METHODS.include?(node.children[1])
|
|
85
|
+
|
|
86
|
+
receiver = node.children[0]
|
|
87
|
+
return unless receiver&.type == :lvar
|
|
88
|
+
|
|
89
|
+
model_name = new_model_vars[receiver.children[0]]
|
|
90
|
+
add_issue(node, model_name, node.children[1]) if model_name
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def model_new_call?(node)
|
|
94
|
+
return false unless node.is_a?(Parser::AST::Node) && node.type == :send
|
|
95
|
+
|
|
96
|
+
node.children[1] == :new && node.children[0]&.type == :const &&
|
|
97
|
+
@uniqueness_models.include?(const_name(node.children[0]))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def const_name(node)
|
|
101
|
+
return "" unless node.is_a?(Parser::AST::Node) && node.type == :const
|
|
102
|
+
|
|
103
|
+
parent = node.children[0]
|
|
104
|
+
name = node.children[1].to_s
|
|
105
|
+
parent ? "#{const_name(parent)}::#{name}" : name
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def add_issue(node, model_name, method_name)
|
|
109
|
+
@issues << create_issue(
|
|
110
|
+
file_path: @file_path,
|
|
111
|
+
line_number: node.loc.line,
|
|
112
|
+
message: "`#{model_name}.#{method_name}` inside iteration — uniqueness validation causes SELECT per record",
|
|
113
|
+
suggestion: "Use `insert_all` with unique index constraints, or batch-validate before saving."
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
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
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
class ValidationParser
|
|
5
|
+
attr_reader :uniqueness_models
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@uniqueness_models = Set.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def parse_model(ast, model_name)
|
|
12
|
+
return unless ast
|
|
13
|
+
|
|
14
|
+
traverse(ast, model_name)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def traverse(node, model_name)
|
|
20
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
21
|
+
|
|
22
|
+
check_uniqueness(node, model_name)
|
|
23
|
+
node.children.each { |child| traverse(child, model_name) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def check_uniqueness(node, model_name)
|
|
27
|
+
return unless node.type == :send && node.children[0].nil?
|
|
28
|
+
|
|
29
|
+
case node.children[1]
|
|
30
|
+
when :validates
|
|
31
|
+
@uniqueness_models << model_name if uniqueness_option?(node)
|
|
32
|
+
when :validates_uniqueness_of
|
|
33
|
+
@uniqueness_models << model_name
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def uniqueness_option?(node)
|
|
38
|
+
node.children[2..].any? do |arg|
|
|
39
|
+
next false unless arg.is_a?(Parser::AST::Node) && arg.type == :hash
|
|
40
|
+
|
|
41
|
+
arg.children.any? { |pair| uniqueness_pair?(pair) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def uniqueness_pair?(pair)
|
|
46
|
+
return false unless pair.type == :pair
|
|
47
|
+
|
|
48
|
+
key = pair.children[0]
|
|
49
|
+
key&.type == :sym && key.children[0] == :uniqueness
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
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,14 +1,14 @@
|
|
|
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
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-03-
|
|
11
|
+
date: 2026-03-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ast
|
|
@@ -78,11 +78,13 @@ files:
|
|
|
78
78
|
- lib/eager_eye/detectors/pluck_to_array.rb
|
|
79
79
|
- lib/eager_eye/detectors/scope_chain_n_plus_one.rb
|
|
80
80
|
- lib/eager_eye/detectors/serializer_nesting.rb
|
|
81
|
+
- lib/eager_eye/detectors/validation_n_plus_one.rb
|
|
81
82
|
- lib/eager_eye/fixer_registry.rb
|
|
82
83
|
- lib/eager_eye/fixers/base.rb
|
|
83
84
|
- lib/eager_eye/fixers/pluck_to_select.rb
|
|
84
85
|
- lib/eager_eye/generators/install_generator.rb
|
|
85
86
|
- lib/eager_eye/issue.rb
|
|
87
|
+
- lib/eager_eye/method_query_parser.rb
|
|
86
88
|
- lib/eager_eye/railtie.rb
|
|
87
89
|
- lib/eager_eye/reporters/base.rb
|
|
88
90
|
- lib/eager_eye/reporters/console.rb
|
|
@@ -90,6 +92,7 @@ files:
|
|
|
90
92
|
- lib/eager_eye/rspec.rb
|
|
91
93
|
- lib/eager_eye/rspec/matchers.rb
|
|
92
94
|
- lib/eager_eye/scope_parser.rb
|
|
95
|
+
- lib/eager_eye/validation_parser.rb
|
|
93
96
|
- lib/eager_eye/version.rb
|
|
94
97
|
- sig/eager_eye.rbs
|
|
95
98
|
homepage: https://github.com/hamzagedikkaya/eager_eye
|