eager_eye 1.2.5 → 1.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +1 -1
- data/lib/eager_eye/analyzer.rb +25 -39
- data/lib/eager_eye/comment_parser.rb +0 -1
- data/lib/eager_eye/delegation_parser.rb +13 -21
- data/lib/eager_eye/detectors/base.rb +29 -2
- data/lib/eager_eye/detectors/callback_query.rb +5 -35
- data/lib/eager_eye/detectors/concerns/class_inspector.rb +51 -0
- data/lib/eager_eye/detectors/count_in_iteration.rb +5 -47
- data/lib/eager_eye/detectors/custom_method_query.rb +16 -58
- data/lib/eager_eye/detectors/decorator_n_plus_one.rb +18 -71
- data/lib/eager_eye/detectors/delegation_n_plus_one.rb +26 -59
- data/lib/eager_eye/detectors/loop_association.rb +25 -40
- data/lib/eager_eye/detectors/serializer_nesting.rb +5 -44
- data/lib/eager_eye/reporters/base.rb +5 -9
- data/lib/eager_eye/reporters/console.rb +6 -6
- data/lib/eager_eye/version.rb +1 -1
- 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: f996747b6c12e539180e66cc74c11c70acfab4a6e4b84aed4039cc7b58dafe53
|
|
4
|
+
data.tar.gz: f23d5a21ceecc39c71b0303a5748472a8ea5e0f5f3256ffd5a10084a5af8a39c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a75d25669ab2d7e96dbccb107ae30f817e5fd6a291651b68bc56dbea21f50bd5c8b24b0633dd21d6faeeb5a8abd999abc6baf57ac9c7e8c3c06aa4542dc570d3
|
|
7
|
+
data.tar.gz: 7d2e5af151c02923bf4e64f05bc277711228729fa08ddee3dd79699623efc33f3689290c1739c025d7851db0b59d90feeac99e3a65c9a13862fac8f36ddf46b6
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.2.6] - 2026-02-25
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Extract shared class-inspection logic into `ClassInspector` concern to reduce duplication across detectors
|
|
15
|
+
- Refactor all detectors to use `ClassInspector` for parent class and naming convention checks
|
|
16
|
+
- Simplify `Analyzer` by removing redundant delegation and streamlining detector orchestration
|
|
17
|
+
- Clean up `DelegationParser` with leaner parsing logic
|
|
18
|
+
- Improve `Base` detector with consolidated helper methods
|
|
19
|
+
- Streamline reporter classes (`Base`, `Console`) for clarity and consistency
|
|
20
|
+
|
|
21
|
+
### Removed
|
|
22
|
+
|
|
23
|
+
- Remove duplicated class-matching code from `CallbackQuery`, `CountInIteration`, `CustomMethodQuery`, `DecoratorNPlusOne`, `DelegationNPlusOne`, `LoopAssociation`, and `SerializerNesting`
|
|
24
|
+
|
|
10
25
|
## [1.2.5] - 2026-02-21
|
|
11
26
|
|
|
12
27
|
### 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.6-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>
|
data/lib/eager_eye/analyzer.rb
CHANGED
|
@@ -27,38 +27,30 @@ module EagerEye
|
|
|
27
27
|
|
|
28
28
|
def run
|
|
29
29
|
@issues = []
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
analyze_files
|
|
30
|
+
collect_model_metadata
|
|
31
|
+
ruby_files.each { |file_path| analyze_file(file_path) }
|
|
33
32
|
@issues
|
|
34
33
|
end
|
|
35
34
|
|
|
36
35
|
private
|
|
37
36
|
|
|
38
|
-
def
|
|
37
|
+
def collect_model_metadata
|
|
39
38
|
model_files.each do |file_path|
|
|
40
39
|
ast = parse_source(File.read(file_path))
|
|
41
40
|
next unless ast
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
parser.parse_model(ast, extract_model_name(file_path))
|
|
45
|
-
@association_preloads.merge!(parser.preloaded_associations)
|
|
46
|
-
end
|
|
47
|
-
rescue StandardError
|
|
48
|
-
nil
|
|
49
|
-
end
|
|
42
|
+
model_name = extract_model_name(file_path)
|
|
50
43
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
next unless ast
|
|
44
|
+
assoc_parser = AssociationParser.new
|
|
45
|
+
assoc_parser.parse_model(ast, model_name)
|
|
46
|
+
@association_preloads.merge!(assoc_parser.preloaded_associations)
|
|
55
47
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
@delegation_maps.merge!(
|
|
48
|
+
deleg_parser = DelegationParser.new
|
|
49
|
+
deleg_parser.parse_model(ast, model_name)
|
|
50
|
+
@delegation_maps.merge!(deleg_parser.delegation_maps)
|
|
51
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
52
|
+
next
|
|
59
53
|
end
|
|
60
|
-
rescue StandardError
|
|
61
|
-
nil
|
|
62
54
|
end
|
|
63
55
|
|
|
64
56
|
def model_files
|
|
@@ -66,25 +58,19 @@ module EagerEye
|
|
|
66
58
|
end
|
|
67
59
|
|
|
68
60
|
def extract_model_name(file_path)
|
|
69
|
-
File.basename(file_path, ".rb")
|
|
61
|
+
name = File.basename(file_path, ".rb")
|
|
62
|
+
name.respond_to?(:camelize) ? name.camelize : name.split("_").map(&:capitalize).join
|
|
70
63
|
end
|
|
71
64
|
|
|
72
|
-
def
|
|
73
|
-
|
|
65
|
+
def ruby_files
|
|
66
|
+
paths.flat_map { |path| resolve_path(path) }.reject { |file| excluded?(file) }
|
|
74
67
|
end
|
|
75
68
|
|
|
76
|
-
def
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
[path]
|
|
80
|
-
elsif File.directory?(path)
|
|
81
|
-
Dir.glob(File.join(path, "**", "*.rb"))
|
|
82
|
-
else
|
|
83
|
-
Dir.glob(path)
|
|
84
|
-
end
|
|
85
|
-
end
|
|
69
|
+
def resolve_path(path)
|
|
70
|
+
return [path] if File.file?(path)
|
|
71
|
+
return Dir.glob(File.join(path, "**", "*.rb")) if File.directory?(path)
|
|
86
72
|
|
|
87
|
-
|
|
73
|
+
Dir.glob(path)
|
|
88
74
|
end
|
|
89
75
|
|
|
90
76
|
def excluded?(file_path)
|
|
@@ -103,9 +89,10 @@ module EagerEye
|
|
|
103
89
|
|
|
104
90
|
enabled_detectors.each do |detector|
|
|
105
91
|
file_issues = detector.detect(*detector_args(detector, ast, file_path))
|
|
106
|
-
file_issues.
|
|
107
|
-
|
|
108
|
-
|
|
92
|
+
@issues.concat(file_issues.select do |issue|
|
|
93
|
+
!comment_parser.disabled_at?(issue.line_number, issue.detector) &&
|
|
94
|
+
issue.meets_minimum_severity?(min_severity)
|
|
95
|
+
end)
|
|
109
96
|
end
|
|
110
97
|
rescue Errno::ENOENT, Errno::EACCES => e
|
|
111
98
|
warn "EagerEye: Could not read file #{file_path}: #{e.message}"
|
|
@@ -126,8 +113,7 @@ module EagerEye
|
|
|
126
113
|
|
|
127
114
|
def enabled_detectors
|
|
128
115
|
@enabled_detectors ||= EagerEye.configuration.enabled_detectors.filter_map do |name|
|
|
129
|
-
|
|
130
|
-
detector_class&.new
|
|
116
|
+
DETECTOR_CLASSES[name]&.new
|
|
131
117
|
end
|
|
132
118
|
end
|
|
133
119
|
end
|
|
@@ -5,7 +5,6 @@ module EagerEye
|
|
|
5
5
|
FILE_DISABLE_PATTERN = /eager_eye:disable-file\s+(.+?)(?:\s+--|$)/i
|
|
6
6
|
NEXT_LINE_PATTERN = /eager_eye:disable-next-line(?:\s+(.+?))?(?:\s+--|$)/i
|
|
7
7
|
BLOCK_START_PATTERN = /eager_eye:disable-block(?:\s+(.+?))?(?:\s+--|$)/i
|
|
8
|
-
BLOCK_END_PATTERN = /eager_eye:enable-block(?:\s+(.+?))?(?:\s+--|$)/i
|
|
9
8
|
INLINE_DISABLE_PATTERN = /eager_eye:disable\s+(.+?)(?:\s+--|$)/i
|
|
10
9
|
ENABLE_PATTERN = /eager_eye:enable(?:\s+(.+?))?(?:\s+--|$)/i
|
|
11
10
|
|
|
@@ -24,50 +24,42 @@ module EagerEye
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def check_delegate(node, model_name)
|
|
27
|
-
return unless
|
|
27
|
+
return unless delegate_call?(node)
|
|
28
28
|
|
|
29
29
|
args = node.children[2..]
|
|
30
|
-
methods =
|
|
30
|
+
methods = extract_sym_args(args)
|
|
31
31
|
return if methods.empty?
|
|
32
32
|
|
|
33
33
|
to_target = extract_to_target(args)
|
|
34
34
|
return unless to_target
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
@delegation_maps[model_name] ||= {}
|
|
37
|
+
methods.each { |m| @delegation_maps[model_name][m] = to_target }
|
|
37
38
|
end
|
|
38
39
|
|
|
39
|
-
def
|
|
40
|
+
def delegate_call?(node)
|
|
40
41
|
node.type == :send && node.children[0].nil? && node.children[1] == :delegate
|
|
41
42
|
end
|
|
42
43
|
|
|
43
|
-
def
|
|
44
|
+
def extract_sym_args(args)
|
|
44
45
|
args.select { |a| a&.type == :sym }.map { |a| a.children[0] }
|
|
45
46
|
end
|
|
46
47
|
|
|
47
|
-
def register_delegates(model_name, methods, to_target)
|
|
48
|
-
@delegation_maps[model_name] ||= {}
|
|
49
|
-
methods.each { |m| @delegation_maps[model_name][m] = to_target }
|
|
50
|
-
end
|
|
51
|
-
|
|
52
48
|
def extract_to_target(args)
|
|
53
49
|
hash_arg = args.find { |a| a&.type == :hash }
|
|
54
50
|
return unless hash_arg
|
|
55
51
|
|
|
56
|
-
to_pair = hash_arg
|
|
57
|
-
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def extract_sym_value(node)
|
|
61
|
-
return unless node
|
|
52
|
+
to_pair = find_to_pair(hash_arg)
|
|
53
|
+
return unless to_pair
|
|
62
54
|
|
|
63
|
-
value =
|
|
55
|
+
value = to_pair.children[1]
|
|
64
56
|
value.children[0] if value&.type == :sym
|
|
65
57
|
end
|
|
66
58
|
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
pair.children[0]&.type == :sym &&
|
|
70
|
-
|
|
59
|
+
def find_to_pair(hash_node)
|
|
60
|
+
hash_node.children.find do |p|
|
|
61
|
+
p.type == :pair && p.children[0]&.type == :sym && p.children[0].children[0] == :to
|
|
62
|
+
end
|
|
71
63
|
end
|
|
72
64
|
end
|
|
73
65
|
end
|
|
@@ -22,13 +22,12 @@ module EagerEye
|
|
|
22
22
|
protected
|
|
23
23
|
|
|
24
24
|
def create_issue(file_path:, line_number:, message:, severity: nil, suggestion: nil)
|
|
25
|
-
resolved_severity = severity || configured_severity
|
|
26
25
|
Issue.new(
|
|
27
26
|
detector: self.class.detector_name,
|
|
28
27
|
file_path: file_path,
|
|
29
28
|
line_number: line_number,
|
|
30
29
|
message: message,
|
|
31
|
-
severity:
|
|
30
|
+
severity: severity || configured_severity,
|
|
32
31
|
suggestion: suggestion
|
|
33
32
|
)
|
|
34
33
|
end
|
|
@@ -73,6 +72,34 @@ module EagerEye
|
|
|
73
72
|
symbols.add(key.children[0]) if key&.type == :sym
|
|
74
73
|
end
|
|
75
74
|
end
|
|
75
|
+
|
|
76
|
+
def extract_block_variable(block_node)
|
|
77
|
+
args = block_node&.children&.[](1)
|
|
78
|
+
first_arg = args&.children&.first
|
|
79
|
+
first_arg&.type == :arg ? first_arg.children[0] : nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def receiver_chain_starts_with?(node, target_var)
|
|
83
|
+
return false unless node.is_a?(Parser::AST::Node)
|
|
84
|
+
|
|
85
|
+
case node.type
|
|
86
|
+
when :lvar then node.children[0] == target_var
|
|
87
|
+
when :send then receiver_chain_starts_with?(node.children[0], target_var)
|
|
88
|
+
else false
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def reconstruct_chain(node)
|
|
93
|
+
return "" unless node.is_a?(Parser::AST::Node)
|
|
94
|
+
|
|
95
|
+
case node.type
|
|
96
|
+
when :lvar then node.children[0].to_s
|
|
97
|
+
when :send
|
|
98
|
+
receiver_str = reconstruct_chain(node.children[0])
|
|
99
|
+
receiver_str.empty? ? node.children[1].to_s : "#{receiver_str}.#{node.children[1]}"
|
|
100
|
+
else ""
|
|
101
|
+
end
|
|
102
|
+
end
|
|
76
103
|
end
|
|
77
104
|
end
|
|
78
105
|
end
|
|
@@ -41,7 +41,6 @@ module EagerEye
|
|
|
41
41
|
@issues = []
|
|
42
42
|
@file_path = file_path
|
|
43
43
|
@callback_methods = {}
|
|
44
|
-
|
|
45
44
|
return @issues unless ast
|
|
46
45
|
|
|
47
46
|
find_callback_definitions(ast)
|
|
@@ -67,9 +66,7 @@ module EagerEye
|
|
|
67
66
|
node.children[2..].each do |arg|
|
|
68
67
|
next unless arg.is_a?(Parser::AST::Node) && arg.type == :sym
|
|
69
68
|
|
|
70
|
-
|
|
71
|
-
callback_type = node.children[1]
|
|
72
|
-
@callback_methods[method_name] = callback_type
|
|
69
|
+
@callback_methods[arg.children[0]] = node.children[1]
|
|
73
70
|
end
|
|
74
71
|
end
|
|
75
72
|
|
|
@@ -77,9 +74,8 @@ module EagerEye
|
|
|
77
74
|
return unless node.is_a?(Parser::AST::Node)
|
|
78
75
|
|
|
79
76
|
if node.type == :def && @callback_methods.key?(node.children[0])
|
|
80
|
-
method_name = node.children[0]
|
|
81
77
|
body = node.children[2]
|
|
82
|
-
find_iterations_with_queries(body,
|
|
78
|
+
find_iterations_with_queries(body, node.children[0], @callback_methods[node.children[0]]) if body
|
|
83
79
|
end
|
|
84
80
|
|
|
85
81
|
node.children.each { |child| check_callback_methods(child) }
|
|
@@ -131,7 +127,6 @@ module EagerEye
|
|
|
131
127
|
end
|
|
132
128
|
|
|
133
129
|
def add_query_issue(node, method_name, callback_type)
|
|
134
|
-
query_method = node.children[1]
|
|
135
130
|
suggestion = if transactional_callback?(callback_type)
|
|
136
131
|
"Callbacks run on every save/create/update. Move the query outside the iteration or preload data"
|
|
137
132
|
else
|
|
@@ -141,7 +136,7 @@ module EagerEye
|
|
|
141
136
|
@issues << create_issue(
|
|
142
137
|
file_path: @file_path,
|
|
143
138
|
line_number: node.loc.line,
|
|
144
|
-
message: "Query method `.#{
|
|
139
|
+
message: "Query method `.#{node.children[1]}` found in `#{callback_type}` callback `:#{method_name}`",
|
|
145
140
|
severity: :warning,
|
|
146
141
|
suggestion: suggestion
|
|
147
142
|
)
|
|
@@ -167,29 +162,6 @@ module EagerEye
|
|
|
167
162
|
TRANSACTIONAL_CALLBACKS.include?(callback_type)
|
|
168
163
|
end
|
|
169
164
|
|
|
170
|
-
def extract_block_variable(block_node)
|
|
171
|
-
args_node = block_node.children[1]
|
|
172
|
-
return nil unless args_node&.type == :args
|
|
173
|
-
|
|
174
|
-
first_arg = args_node.children[0]
|
|
175
|
-
return nil unless first_arg&.type == :arg
|
|
176
|
-
|
|
177
|
-
first_arg.children[0]
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def receiver_chain_starts_with?(node, block_var)
|
|
181
|
-
return false unless node.is_a?(Parser::AST::Node)
|
|
182
|
-
|
|
183
|
-
case node.type
|
|
184
|
-
when :lvar
|
|
185
|
-
node.children[0] == block_var
|
|
186
|
-
when :send
|
|
187
|
-
receiver_chain_starts_with?(node.children[0], block_var)
|
|
188
|
-
else
|
|
189
|
-
false
|
|
190
|
-
end
|
|
191
|
-
end
|
|
192
|
-
|
|
193
165
|
def non_ar_collection?(node)
|
|
194
166
|
ns = root_namespace(node)
|
|
195
167
|
ns && NON_AR_NAMESPACES.include?(ns)
|
|
@@ -199,10 +171,8 @@ module EagerEye
|
|
|
199
171
|
return nil unless node.is_a?(Parser::AST::Node)
|
|
200
172
|
|
|
201
173
|
case node.type
|
|
202
|
-
when :const
|
|
203
|
-
|
|
204
|
-
when :send, :block
|
|
205
|
-
root_namespace(node.children[0])
|
|
174
|
+
when :const then node.children[0].nil? ? node.children[1].to_s : root_namespace(node.children[0])
|
|
175
|
+
when :send, :block then root_namespace(node.children[0])
|
|
206
176
|
end
|
|
207
177
|
end
|
|
208
178
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
module Detectors
|
|
5
|
+
module Concerns
|
|
6
|
+
module ClassInspector
|
|
7
|
+
HAS_MANY_ASSOCIATIONS = %w[
|
|
8
|
+
authors users owners creators admins members customers clients
|
|
9
|
+
posts articles comments categories tags children companies organizations
|
|
10
|
+
projects tasks items orders products accounts profiles settings
|
|
11
|
+
images avatars photos attachments documents
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
ACTIVE_STORAGE_METHODS = %i[
|
|
15
|
+
attached? attach attachment attachments blob blobs purge purge_later variant preview
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def const_to_string(node)
|
|
21
|
+
return nil unless node&.type == :const
|
|
22
|
+
|
|
23
|
+
parts = []
|
|
24
|
+
current = node
|
|
25
|
+
while current&.type == :const
|
|
26
|
+
parts.unshift(current.children[1].to_s)
|
|
27
|
+
current = current.children[0]
|
|
28
|
+
end
|
|
29
|
+
parts.join("::")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def extract_class_name(class_node)
|
|
33
|
+
name_node = class_node.children[0]
|
|
34
|
+
name_node.children[1].to_s if name_node&.type == :const
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def likely_association?(method_name)
|
|
38
|
+
HAS_MANY_ASSOCIATIONS.include?(method_name.to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def collect_active_storage_lines(body)
|
|
42
|
+
lines = Set.new
|
|
43
|
+
traverse_ast(body) do |node|
|
|
44
|
+
lines << node.loc.line if node.type == :send && ACTIVE_STORAGE_METHODS.include?(node.children[1])
|
|
45
|
+
end
|
|
46
|
+
lines
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -15,7 +15,6 @@ module EagerEye
|
|
|
15
15
|
def detect(ast, file_path)
|
|
16
16
|
@issues = []
|
|
17
17
|
@file_path = file_path
|
|
18
|
-
|
|
19
18
|
return @issues unless ast
|
|
20
19
|
|
|
21
20
|
find_iteration_blocks(ast) do |block_body, block_var|
|
|
@@ -60,64 +59,23 @@ module EagerEye
|
|
|
60
59
|
def array_returning_method?(node)
|
|
61
60
|
return false unless node.is_a?(Parser::AST::Node) && node.type == :send
|
|
62
61
|
|
|
63
|
-
|
|
64
|
-
ARRAY_METHOD_SUFFIXES.any? { |suffix| method_name.end_with?(suffix) }
|
|
62
|
+
ARRAY_METHOD_SUFFIXES.any? { |suffix| node.children[1].to_s.end_with?(suffix) }
|
|
65
63
|
end
|
|
66
64
|
|
|
67
65
|
def association_call_on_block_var?(node, block_var)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
receiver = node.children[0]
|
|
71
|
-
return false unless receiver.is_a?(Parser::AST::Node)
|
|
72
|
-
|
|
73
|
-
return true if receiver.type == :lvar && receiver.children[0] == block_var
|
|
74
|
-
|
|
75
|
-
receiver.type == :send && chain_starts_with_block_var?(receiver, block_var)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def chain_starts_with_block_var?(node, block_var)
|
|
79
|
-
return false unless node.is_a?(Parser::AST::Node)
|
|
80
|
-
|
|
81
|
-
case node.type
|
|
82
|
-
when :lvar then node.children[0] == block_var
|
|
83
|
-
when :send then chain_starts_with_block_var?(node.children[0], block_var)
|
|
84
|
-
else false
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def extract_block_variable(block_node)
|
|
89
|
-
args_node = block_node.children[1]
|
|
90
|
-
return nil unless args_node&.type == :args
|
|
91
|
-
|
|
92
|
-
first_arg = args_node.children[0]
|
|
93
|
-
first_arg&.type == :arg ? first_arg.children[0] : nil
|
|
66
|
+
node.is_a?(Parser::AST::Node) && node.type == :send &&
|
|
67
|
+
receiver_chain_starts_with?(node.children[0], block_var)
|
|
94
68
|
end
|
|
95
69
|
|
|
96
70
|
def add_issue(node)
|
|
97
|
-
|
|
98
|
-
|
|
71
|
+
chain = reconstruct_chain(node.children[0])
|
|
99
72
|
@issues << create_issue(
|
|
100
73
|
file_path: @file_path,
|
|
101
74
|
line_number: node.loc.line,
|
|
102
|
-
message: "`.count` called on `#{
|
|
75
|
+
message: "`.count` called on `#{chain}` inside iteration always executes a COUNT query",
|
|
103
76
|
suggestion: "Use `.size` instead (uses loaded collection) or add `counter_cache: true`"
|
|
104
77
|
)
|
|
105
78
|
end
|
|
106
|
-
|
|
107
|
-
def reconstruct_chain(node)
|
|
108
|
-
return "" unless node.is_a?(Parser::AST::Node)
|
|
109
|
-
|
|
110
|
-
case node.type
|
|
111
|
-
when :lvar
|
|
112
|
-
node.children[0].to_s
|
|
113
|
-
when :send
|
|
114
|
-
receiver_str = reconstruct_chain(node.children[0])
|
|
115
|
-
method = node.children[1]
|
|
116
|
-
receiver_str.empty? ? method.to_s : "#{receiver_str}.#{method}"
|
|
117
|
-
else
|
|
118
|
-
""
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
79
|
end
|
|
122
80
|
end
|
|
123
81
|
end
|
|
@@ -22,8 +22,7 @@ module EagerEye
|
|
|
22
22
|
@file_path = file_path
|
|
23
23
|
|
|
24
24
|
find_iteration_blocks(ast) do |block_body, block_var, collection, definitions|
|
|
25
|
-
|
|
26
|
-
check_block_for_query_methods(block_body, block_var, is_array_collection)
|
|
25
|
+
check_block_for_query_methods(block_body, block_var, collection_is_array?(collection, definitions))
|
|
27
26
|
end
|
|
28
27
|
|
|
29
28
|
@issues
|
|
@@ -71,52 +70,29 @@ module EagerEye
|
|
|
71
70
|
return true if receiver_ends_with_safe_transform_method?(node.children[0])
|
|
72
71
|
|
|
73
72
|
SAFE_QUERY_METHODS.include?(node.children[1]) &&
|
|
74
|
-
is_array_collection &&
|
|
73
|
+
is_array_collection && direct_block_var?(node.children[0], block_var)
|
|
75
74
|
end
|
|
76
75
|
|
|
77
|
-
def
|
|
76
|
+
def direct_block_var?(node, block_var)
|
|
78
77
|
node.is_a?(Parser::AST::Node) && node.type == :lvar && node.children[0] == block_var
|
|
79
78
|
end
|
|
80
79
|
|
|
81
|
-
def receiver_chain_starts_with?(node, block_var)
|
|
82
|
-
return false unless node.is_a?(Parser::AST::Node)
|
|
83
|
-
|
|
84
|
-
case node.type
|
|
85
|
-
when :lvar then node.children[0] == block_var
|
|
86
|
-
when :send then receiver_chain_starts_with?(node.children[0], block_var)
|
|
87
|
-
else false
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def extract_block_variable(block_node)
|
|
92
|
-
args_node = block_node.children[1]
|
|
93
|
-
return nil unless args_node&.type == :args
|
|
94
|
-
|
|
95
|
-
first_arg = args_node.children[0]
|
|
96
|
-
first_arg&.type == :arg ? first_arg.children[0] : nil
|
|
97
|
-
end
|
|
98
|
-
|
|
99
80
|
def collection_is_array?(node, definitions = {}, visited = Set.new)
|
|
100
81
|
return false unless node.is_a?(Parser::AST::Node)
|
|
101
|
-
return false
|
|
102
|
-
|
|
103
|
-
visited.add(node.object_id)
|
|
82
|
+
return false unless visited.add?(node.object_id)
|
|
104
83
|
|
|
105
84
|
return true if %i[array hash].include?(node.type)
|
|
106
|
-
return check_lvar_collection?(node, definitions, visited) if node.type == :lvar
|
|
107
|
-
return check_send_collection?(node, definitions, visited) if node.type == :send
|
|
108
|
-
|
|
109
|
-
false
|
|
110
|
-
end
|
|
111
85
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
86
|
+
case node.type
|
|
87
|
+
when :lvar
|
|
88
|
+
defn = definitions[node.children[0]]
|
|
89
|
+
defn && collection_is_array?(defn, definitions, visited)
|
|
90
|
+
when :send then send_returns_array?(node, definitions, visited)
|
|
91
|
+
else false
|
|
92
|
+
end
|
|
117
93
|
end
|
|
118
94
|
|
|
119
|
-
def
|
|
95
|
+
def send_returns_array?(node, definitions, visited)
|
|
120
96
|
method_name = node.children[1]
|
|
121
97
|
return true if %i[map select collect flat_map uniq compact].include?(method_name)
|
|
122
98
|
return true if SAFE_TRANSFORM_METHODS.include?(method_name)
|
|
@@ -128,41 +104,23 @@ module EagerEye
|
|
|
128
104
|
return false unless node.is_a?(Parser::AST::Node) && node.type == :send
|
|
129
105
|
|
|
130
106
|
method_name = node.children[1]
|
|
131
|
-
SAFE_TRANSFORM_METHODS.include?(method_name) ||
|
|
107
|
+
SAFE_TRANSFORM_METHODS.include?(method_name) ||
|
|
108
|
+
ARRAY_COLUMN_SUFFIXES.any? { |suffix| method_name.to_s.end_with?(suffix) }
|
|
132
109
|
end
|
|
133
110
|
|
|
134
111
|
def receiver_is_query_chain?(node)
|
|
135
112
|
node.is_a?(Parser::AST::Node) && node.type == :send && QUERY_METHODS.include?(node.children[1])
|
|
136
113
|
end
|
|
137
114
|
|
|
138
|
-
def array_column_method?(method_name)
|
|
139
|
-
method_str = method_name.to_s
|
|
140
|
-
ARRAY_COLUMN_SUFFIXES.any? { |suffix| method_str.end_with?(suffix) }
|
|
141
|
-
end
|
|
142
|
-
|
|
143
115
|
def add_issue(node)
|
|
144
|
-
|
|
145
|
-
association_chain = reconstruct_chain(node.children[0])
|
|
146
|
-
|
|
116
|
+
chain = reconstruct_chain(node.children[0])
|
|
147
117
|
@issues << create_issue(
|
|
148
118
|
file_path: @file_path,
|
|
149
119
|
line_number: node.loc.line,
|
|
150
|
-
message: "Query method `.#{
|
|
120
|
+
message: "Query method `.#{node.children[1]}` called on `#{chain}` inside iteration",
|
|
151
121
|
suggestion: "This query executes on each iteration. Consider preloading data or restructuring the query."
|
|
152
122
|
)
|
|
153
123
|
end
|
|
154
|
-
|
|
155
|
-
def reconstruct_chain(node)
|
|
156
|
-
return "" unless node.is_a?(Parser::AST::Node)
|
|
157
|
-
|
|
158
|
-
case node.type
|
|
159
|
-
when :lvar then node.children[0].to_s
|
|
160
|
-
when :send
|
|
161
|
-
receiver_str = reconstruct_chain(node.children[0])
|
|
162
|
-
receiver_str.empty? ? node.children[1].to_s : "#{receiver_str}.#{node.children[1]}"
|
|
163
|
-
else ""
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
124
|
end
|
|
167
125
|
end
|
|
168
126
|
end
|
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "concerns/class_inspector"
|
|
4
|
+
|
|
3
5
|
module EagerEye
|
|
4
6
|
module Detectors
|
|
5
7
|
class DecoratorNPlusOne < Base
|
|
8
|
+
include Concerns::ClassInspector
|
|
9
|
+
|
|
6
10
|
DECORATOR_PATTERNS = %w[Draper::Decorator SimpleDelegator Delegator].freeze
|
|
7
11
|
OBJECT_REFS = %i[object __getobj__ source model].freeze
|
|
8
|
-
ACTIVE_STORAGE_METHODS = %i[attached? attach attachment attachments blob blobs purge purge_later variant
|
|
9
|
-
preview].freeze
|
|
10
|
-
HAS_MANY_ASSOCIATIONS = %w[
|
|
11
|
-
authors users owners creators admins members customers clients
|
|
12
|
-
posts articles comments categories tags children companies organizations
|
|
13
|
-
projects tasks items orders products accounts profiles settings
|
|
14
|
-
images avatars photos attachments documents
|
|
15
|
-
].freeze
|
|
16
12
|
|
|
17
13
|
def self.detector_name
|
|
18
14
|
:decorator_n_plus_one
|
|
@@ -22,34 +18,21 @@ module EagerEye
|
|
|
22
18
|
return [] unless ast
|
|
23
19
|
|
|
24
20
|
issues = []
|
|
25
|
-
|
|
26
21
|
traverse_ast(ast) do |node|
|
|
27
|
-
next unless decorator_class?(node)
|
|
22
|
+
next unless node.type == :class && decorator_class?(node)
|
|
28
23
|
|
|
29
24
|
find_association_accesses(node, file_path, issues)
|
|
30
25
|
end
|
|
31
|
-
|
|
32
26
|
issues
|
|
33
27
|
end
|
|
34
28
|
|
|
35
29
|
private
|
|
36
30
|
|
|
37
31
|
def decorator_class?(node)
|
|
38
|
-
return false unless node.type == :class
|
|
39
|
-
|
|
40
32
|
class_name = extract_class_name(node)
|
|
41
33
|
return false unless class_name
|
|
42
34
|
|
|
43
|
-
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def decorator_name_pattern?(class_name)
|
|
47
|
-
class_name.end_with?("Decorator", "Presenter", "ViewObject")
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def extract_class_name(class_node)
|
|
51
|
-
name_node = class_node.children[0]
|
|
52
|
-
name_node.children[1].to_s if name_node&.type == :const
|
|
35
|
+
class_name.end_with?("Decorator", "Presenter", "ViewObject") || inherits_from_decorator?(node)
|
|
53
36
|
end
|
|
54
37
|
|
|
55
38
|
def inherits_from_decorator?(class_node)
|
|
@@ -60,18 +43,6 @@ module EagerEye
|
|
|
60
43
|
DECORATOR_PATTERNS.any? { |p| parent_name&.include?(p.split("::").last) }
|
|
61
44
|
end
|
|
62
45
|
|
|
63
|
-
def const_to_string(node)
|
|
64
|
-
return nil unless node&.type == :const
|
|
65
|
-
|
|
66
|
-
parts = []
|
|
67
|
-
current = node
|
|
68
|
-
while current&.type == :const
|
|
69
|
-
parts.unshift(current.children[1].to_s)
|
|
70
|
-
current = current.children[0]
|
|
71
|
-
end
|
|
72
|
-
parts.join("::")
|
|
73
|
-
end
|
|
74
|
-
|
|
75
46
|
def find_association_accesses(class_node, file_path, issues)
|
|
76
47
|
body = class_node.children[2]
|
|
77
48
|
return unless body
|
|
@@ -88,49 +59,25 @@ module EagerEye
|
|
|
88
59
|
|
|
89
60
|
storage_lines = collect_active_storage_lines(method_body)
|
|
90
61
|
traverse_ast(method_body) do |node|
|
|
91
|
-
next unless
|
|
62
|
+
next unless node.type == :send
|
|
63
|
+
next if storage_lines.include?(node.loc.line)
|
|
92
64
|
|
|
93
65
|
receiver = node.children[0]
|
|
94
66
|
method_name = node.children[1]
|
|
95
|
-
|
|
67
|
+
next unless object_reference?(receiver) && likely_association?(method_name)
|
|
68
|
+
|
|
69
|
+
ref = receiver.children[1]
|
|
70
|
+
issues << create_issue(
|
|
71
|
+
file_path: file_path,
|
|
72
|
+
line_number: node.loc.line,
|
|
73
|
+
message: "N+1 in decorator: `#{ref}.#{method_name}` loads association on each decorated object",
|
|
74
|
+
suggestion: "Eager load :#{method_name} in the controller before decorating the collection"
|
|
75
|
+
)
|
|
96
76
|
end
|
|
97
77
|
end
|
|
98
78
|
|
|
99
|
-
def association_access?(node, storage_lines)
|
|
100
|
-
return false unless node.type == :send
|
|
101
|
-
return false if storage_lines.include?(node.loc.line)
|
|
102
|
-
|
|
103
|
-
object_reference?(node.children[0]) && likely_association?(node.children[1])
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def collect_active_storage_lines(body)
|
|
107
|
-
lines = Set.new
|
|
108
|
-
traverse_ast(body) do |node|
|
|
109
|
-
next unless node.type == :send && ACTIVE_STORAGE_METHODS.include?(node.children[1])
|
|
110
|
-
|
|
111
|
-
lines << node.loc.line
|
|
112
|
-
end
|
|
113
|
-
lines
|
|
114
|
-
end
|
|
115
|
-
|
|
116
79
|
def object_reference?(node)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
node.children[0].nil? && OBJECT_REFS.include?(node.children[1])
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def likely_association?(method_name)
|
|
123
|
-
HAS_MANY_ASSOCIATIONS.include?(method_name.to_s)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def create_decorator_issue(file_path, line, receiver, method_name)
|
|
127
|
-
ref = receiver.children[1]
|
|
128
|
-
create_issue(
|
|
129
|
-
file_path: file_path,
|
|
130
|
-
line_number: line,
|
|
131
|
-
message: "N+1 in decorator: `#{ref}.#{method_name}` loads association on each decorated object",
|
|
132
|
-
suggestion: "Eager load :#{method_name} in the controller before decorating the collection"
|
|
133
|
-
)
|
|
80
|
+
node&.type == :send && node.children[0].nil? && OBJECT_REFS.include?(node.children[1])
|
|
134
81
|
end
|
|
135
82
|
end
|
|
136
83
|
end
|
|
@@ -23,14 +23,11 @@ module EagerEye
|
|
|
23
23
|
next unless iteration_block?(node)
|
|
24
24
|
|
|
25
25
|
block_var = extract_block_variable(node)
|
|
26
|
-
next unless block_var
|
|
27
|
-
|
|
28
26
|
block_body = node.children[2]
|
|
29
|
-
next unless block_body
|
|
27
|
+
next unless block_var && block_body
|
|
30
28
|
|
|
31
29
|
collection_node = node.children[0]
|
|
32
|
-
|
|
33
|
-
delegates = build_delegates(model_name, delegation_maps, local_delegates)
|
|
30
|
+
delegates = build_delegates(infer_model_name(collection_node), delegation_maps, local_delegates)
|
|
34
31
|
next if delegates.empty?
|
|
35
32
|
|
|
36
33
|
included = extract_included_associations(collection_node)
|
|
@@ -45,17 +42,13 @@ module EagerEye
|
|
|
45
42
|
def collect_local_delegates(ast)
|
|
46
43
|
delegates = {}
|
|
47
44
|
traverse_ast(ast) do |node|
|
|
48
|
-
next unless
|
|
45
|
+
next unless node.type == :send && node.children[0].nil? && node.children[1] == :delegate
|
|
49
46
|
|
|
50
47
|
extract_delegate_info(node, delegates)
|
|
51
48
|
end
|
|
52
49
|
delegates
|
|
53
50
|
end
|
|
54
51
|
|
|
55
|
-
def delegate_call?(node)
|
|
56
|
-
node.type == :send && node.children[0].nil? && node.children[1] == :delegate
|
|
57
|
-
end
|
|
58
|
-
|
|
59
52
|
def extract_delegate_info(node, delegates)
|
|
60
53
|
args = node.children[2..]
|
|
61
54
|
methods = args.select { |a| a&.type == :sym }.map { |a| a.children[0] }
|
|
@@ -71,21 +64,17 @@ module EagerEye
|
|
|
71
64
|
hash_arg = args.find { |a| a&.type == :hash }
|
|
72
65
|
return unless hash_arg
|
|
73
66
|
|
|
74
|
-
to_pair = hash_arg
|
|
75
|
-
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def extract_sym_value(node)
|
|
79
|
-
return unless node
|
|
67
|
+
to_pair = find_to_pair(hash_arg)
|
|
68
|
+
return unless to_pair
|
|
80
69
|
|
|
81
|
-
value =
|
|
70
|
+
value = to_pair.children[1]
|
|
82
71
|
value.children[0] if value&.type == :sym
|
|
83
72
|
end
|
|
84
73
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
pair.children[0]&.type == :sym &&
|
|
88
|
-
|
|
74
|
+
def find_to_pair(hash_node)
|
|
75
|
+
hash_node.children.find do |p|
|
|
76
|
+
p.type == :pair && p.children[0]&.type == :sym && p.children[0].children[0] == :to
|
|
77
|
+
end
|
|
89
78
|
end
|
|
90
79
|
|
|
91
80
|
def build_delegates(model_name, delegation_maps, local_delegates)
|
|
@@ -94,26 +83,14 @@ module EagerEye
|
|
|
94
83
|
end
|
|
95
84
|
|
|
96
85
|
def iteration_block?(node)
|
|
97
|
-
node.type == :block &&
|
|
98
|
-
node.children[0]&.type == :send &&
|
|
86
|
+
node.type == :block && node.children[0]&.type == :send &&
|
|
99
87
|
ITERATION_METHODS.include?(node.children[0].children[1])
|
|
100
88
|
end
|
|
101
89
|
|
|
102
|
-
def extract_block_variable(block_node)
|
|
103
|
-
args = block_node&.children&.[](1)
|
|
104
|
-
first_arg = args&.children&.first
|
|
105
|
-
first_arg&.type == :arg ? first_arg.children[0] : nil
|
|
106
|
-
end
|
|
107
|
-
|
|
108
90
|
def infer_model_name(node)
|
|
109
|
-
root = find_root_receiver(node)
|
|
110
|
-
root&.type == :const ? root.children[1].to_s : nil
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def find_root_receiver(node)
|
|
114
91
|
current = node
|
|
115
92
|
current = current.children[0] while current&.type == :send
|
|
116
|
-
current
|
|
93
|
+
current&.type == :const ? current.children[1].to_s : nil
|
|
117
94
|
end
|
|
118
95
|
|
|
119
96
|
def extract_included_associations(collection_node)
|
|
@@ -122,55 +99,45 @@ module EagerEye
|
|
|
122
99
|
|
|
123
100
|
current = collection_node
|
|
124
101
|
while current&.type == :send
|
|
125
|
-
|
|
102
|
+
if PRELOAD_METHODS.include?(current.children[1])
|
|
103
|
+
included.merge(extract_symbols_from_args(extract_method_args(current)))
|
|
104
|
+
end
|
|
126
105
|
current = current.children[0]
|
|
127
106
|
end
|
|
128
107
|
included
|
|
129
108
|
end
|
|
130
109
|
|
|
131
|
-
def extract_from_preload(method_node, included_set)
|
|
132
|
-
args = extract_method_args(method_node)
|
|
133
|
-
included_set.merge(extract_symbols_from_args(args))
|
|
134
|
-
end
|
|
135
|
-
|
|
136
110
|
def find_delegated_calls(block_body, block_var, delegates, included, file_path, issues)
|
|
137
111
|
reported = Set.new
|
|
138
112
|
traverse_ast(block_body) do |node|
|
|
139
113
|
target_assoc = delegation_target(node, block_var, delegates, included, reported)
|
|
140
114
|
next unless target_assoc
|
|
141
115
|
|
|
142
|
-
|
|
116
|
+
method = node.children[1]
|
|
117
|
+
issues << create_issue(
|
|
118
|
+
file_path: file_path,
|
|
119
|
+
line_number: node.loc.line,
|
|
120
|
+
message: "Potential N+1: `#{block_var}.#{method}` is delegated to `#{target_assoc}` — " \
|
|
121
|
+
"loads `#{target_assoc}` on each iteration",
|
|
122
|
+
suggestion: "Use `includes(:#{target_assoc})` before iterating"
|
|
123
|
+
)
|
|
143
124
|
end
|
|
144
125
|
end
|
|
145
126
|
|
|
146
127
|
def delegation_target(node, block_var, delegates, included, reported)
|
|
147
|
-
return unless node.type == :send
|
|
148
|
-
return unless block_var_receiver?(node, block_var)
|
|
128
|
+
return unless node.type == :send && block_var_receiver?(node, block_var)
|
|
149
129
|
|
|
150
130
|
method = node.children[1]
|
|
151
131
|
target_assoc = delegates[method]
|
|
152
|
-
return unless target_assoc
|
|
153
|
-
return if included.include?(target_assoc)
|
|
154
|
-
return unless reported.add?("#{node.loc.line}:#{method}")
|
|
132
|
+
return unless target_assoc && !included.include?(target_assoc)
|
|
155
133
|
|
|
156
|
-
target_assoc
|
|
134
|
+
reported.add?("#{node.loc.line}:#{method}") ? target_assoc : nil
|
|
157
135
|
end
|
|
158
136
|
|
|
159
137
|
def block_var_receiver?(node, block_var)
|
|
160
138
|
receiver = node.children[0]
|
|
161
139
|
receiver&.type == :lvar && receiver.children[0] == block_var
|
|
162
140
|
end
|
|
163
|
-
|
|
164
|
-
def create_delegation_issue(node, block_var, target_assoc, file_path)
|
|
165
|
-
method = node.children[1]
|
|
166
|
-
create_issue(
|
|
167
|
-
file_path: file_path,
|
|
168
|
-
line_number: node.loc.line,
|
|
169
|
-
message: "Potential N+1: `#{block_var}.#{method}` is delegated to `#{target_assoc}` — " \
|
|
170
|
-
"loads `#{target_assoc}` on each iteration",
|
|
171
|
-
suggestion: "Use `includes(:#{target_assoc})` before iterating"
|
|
172
|
-
)
|
|
173
|
-
end
|
|
174
141
|
end
|
|
175
142
|
end
|
|
176
143
|
end
|
|
@@ -51,8 +51,7 @@ module EagerEye
|
|
|
51
51
|
|
|
52
52
|
included = extract_included_associations(collection_node)
|
|
53
53
|
included.merge(extract_variable_preloads(collection_node))
|
|
54
|
-
|
|
55
|
-
included.merge(get_association_preloads(model_name))
|
|
54
|
+
included.merge(get_association_preloads(infer_model_name_from_collection(collection_node)))
|
|
56
55
|
|
|
57
56
|
find_association_calls(block_body, block_var, file_path, issues, included)
|
|
58
57
|
end
|
|
@@ -63,10 +62,9 @@ module EagerEye
|
|
|
63
62
|
private
|
|
64
63
|
|
|
65
64
|
def get_association_preloads(model_name)
|
|
66
|
-
key = "#{model_name}#*"
|
|
67
65
|
preloaded = Set.new
|
|
68
|
-
@association_preloads&.each do |
|
|
69
|
-
preloaded.merge(assocs) if
|
|
66
|
+
@association_preloads&.each do |key, assocs|
|
|
67
|
+
preloaded.merge(assocs) if key.start_with?("#{model_name}#")
|
|
70
68
|
end
|
|
71
69
|
preloaded
|
|
72
70
|
end
|
|
@@ -83,12 +81,6 @@ module EagerEye
|
|
|
83
81
|
ITERATION_METHODS.include?(node.children[0].children[1])
|
|
84
82
|
end
|
|
85
83
|
|
|
86
|
-
def extract_block_variable(block_node)
|
|
87
|
-
args = block_node&.children&.[](1)
|
|
88
|
-
first_arg = args&.children&.first
|
|
89
|
-
first_arg&.type == :arg ? first_arg.children[0] : nil
|
|
90
|
-
end
|
|
91
|
-
|
|
92
84
|
def extract_included_associations(collection_node)
|
|
93
85
|
included = Set.new
|
|
94
86
|
return included unless collection_node&.type == :send
|
|
@@ -142,12 +134,12 @@ module EagerEye
|
|
|
142
134
|
|
|
143
135
|
def find_last_send_method(node)
|
|
144
136
|
current = node
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
end
|
|
137
|
+
while current&.type == :send
|
|
138
|
+
return current.children[1] if SINGLE_RECORD_METHODS.include?(current.children[1])
|
|
148
139
|
|
|
149
|
-
|
|
150
|
-
|
|
140
|
+
current = current.children[0]
|
|
141
|
+
end
|
|
142
|
+
nil
|
|
151
143
|
end
|
|
152
144
|
|
|
153
145
|
def single_record_iteration?(node)
|
|
@@ -158,47 +150,40 @@ module EagerEye
|
|
|
158
150
|
end
|
|
159
151
|
|
|
160
152
|
def extract_includes_from_method(method_node, included_set)
|
|
161
|
-
|
|
162
|
-
included_set.merge(extract_symbols_from_args(args))
|
|
153
|
+
included_set.merge(extract_symbols_from_args(extract_method_args(method_node)))
|
|
163
154
|
end
|
|
164
155
|
|
|
165
156
|
def find_association_calls(node, block_var, file_path, issues, included_associations = Set.new)
|
|
166
157
|
reported = Set.new
|
|
167
158
|
traverse_ast(node) do |child|
|
|
168
|
-
next unless
|
|
169
|
-
|
|
170
|
-
|
|
159
|
+
next unless reportable_association_call?(child, block_var, reported, included_associations)
|
|
160
|
+
|
|
161
|
+
method = child.children[1]
|
|
162
|
+
issues << create_issue(
|
|
163
|
+
file_path: file_path,
|
|
164
|
+
line_number: child.loc.line,
|
|
165
|
+
message: "Potential N+1 query: `#{block_var}.#{method}` called inside loop",
|
|
166
|
+
suggestion: "Use `includes(:#{method})` before iterating"
|
|
167
|
+
)
|
|
171
168
|
end
|
|
172
169
|
end
|
|
173
170
|
|
|
174
|
-
def
|
|
175
|
-
return false unless
|
|
171
|
+
def reportable_association_call?(node, block_var, reported, included)
|
|
172
|
+
return false unless node.type == :send
|
|
176
173
|
|
|
177
|
-
receiver =
|
|
178
|
-
method =
|
|
174
|
+
receiver = node.children[0]
|
|
175
|
+
method = node.children[1]
|
|
179
176
|
return false unless receiver&.type == :lvar && receiver.children[0] == block_var
|
|
180
|
-
return false if
|
|
177
|
+
return false if excluded_method?(method, included)
|
|
181
178
|
|
|
182
|
-
|
|
183
|
-
!reported.include?(key) && reported.add(key)
|
|
179
|
+
reported.add?("#{node.loc.line}:#{method}")
|
|
184
180
|
end
|
|
185
181
|
|
|
186
|
-
def
|
|
182
|
+
def excluded_method?(method, included)
|
|
187
183
|
EXCLUDED_METHODS.include?(method) ||
|
|
188
184
|
!ASSOCIATION_NAMES.include?(method.to_s) ||
|
|
189
185
|
included.include?(method)
|
|
190
186
|
end
|
|
191
|
-
|
|
192
|
-
def add_n_plus_one_issue(node, block_var, file_path, issues, reported)
|
|
193
|
-
method = node.children[1]
|
|
194
|
-
reported << "#{node.loc.line}:#{method}"
|
|
195
|
-
issues << create_issue(
|
|
196
|
-
file_path: file_path,
|
|
197
|
-
line_number: node.loc.line,
|
|
198
|
-
message: "Potential N+1 query: `#{block_var}.#{method}` called inside loop",
|
|
199
|
-
suggestion: "Use `includes(:#{method})` before iterating"
|
|
200
|
-
)
|
|
201
|
-
end
|
|
202
187
|
end
|
|
203
188
|
end
|
|
204
189
|
end
|
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "concerns/class_inspector"
|
|
4
|
+
|
|
3
5
|
module EagerEye
|
|
4
6
|
module Detectors
|
|
5
7
|
class SerializerNesting < Base
|
|
8
|
+
include Concerns::ClassInspector
|
|
9
|
+
|
|
6
10
|
SERIALIZER_PATTERNS = %w[ActiveModel::Serializer ActiveModelSerializers::Model Blueprinter::Base Alba::Resource].freeze
|
|
7
11
|
ATTRIBUTE_METHODS = %i[attribute field attributes].freeze
|
|
8
12
|
OBJECT_REFS = %i[object record resource].freeze
|
|
9
|
-
ACTIVE_STORAGE_METHODS = %i[attached? attach attachment attachments blob blobs purge purge_later variant
|
|
10
|
-
preview].freeze
|
|
11
|
-
HAS_MANY_ASSOCIATIONS = %w[
|
|
12
|
-
authors users owners creators admins members customers clients
|
|
13
|
-
posts articles comments categories tags children companies organizations
|
|
14
|
-
projects tasks items orders products accounts profiles settings
|
|
15
|
-
images avatars photos attachments documents
|
|
16
|
-
].freeze
|
|
17
13
|
|
|
18
14
|
def self.detector_name
|
|
19
15
|
:serializer_nesting
|
|
@@ -23,21 +19,17 @@ module EagerEye
|
|
|
23
19
|
return [] unless ast
|
|
24
20
|
|
|
25
21
|
issues = []
|
|
26
|
-
|
|
27
22
|
traverse_ast(ast) do |node|
|
|
28
|
-
next unless serializer_class?(node)
|
|
23
|
+
next unless node.type == :class && serializer_class?(node)
|
|
29
24
|
|
|
30
25
|
find_nested_associations(node, file_path, issues)
|
|
31
26
|
end
|
|
32
|
-
|
|
33
27
|
issues
|
|
34
28
|
end
|
|
35
29
|
|
|
36
30
|
private
|
|
37
31
|
|
|
38
32
|
def serializer_class?(node)
|
|
39
|
-
return false unless node.type == :class
|
|
40
|
-
|
|
41
33
|
class_name = extract_class_name(node)
|
|
42
34
|
return false unless class_name
|
|
43
35
|
|
|
@@ -45,11 +37,6 @@ module EagerEye
|
|
|
45
37
|
inherits_from_serializer?(node) || includes_serializer_module?(node)
|
|
46
38
|
end
|
|
47
39
|
|
|
48
|
-
def extract_class_name(class_node)
|
|
49
|
-
name_node = class_node.children[0]
|
|
50
|
-
name_node.children[1].to_s if name_node&.type == :const
|
|
51
|
-
end
|
|
52
|
-
|
|
53
40
|
def inherits_from_serializer?(class_node)
|
|
54
41
|
parent_node = class_node.children[1]
|
|
55
42
|
return false unless parent_node
|
|
@@ -73,18 +60,6 @@ module EagerEye
|
|
|
73
60
|
arg && const_to_string(arg)&.include?("Alba")
|
|
74
61
|
end
|
|
75
62
|
|
|
76
|
-
def const_to_string(node)
|
|
77
|
-
return nil unless node&.type == :const
|
|
78
|
-
|
|
79
|
-
parts = []
|
|
80
|
-
current = node
|
|
81
|
-
while current&.type == :const
|
|
82
|
-
parts.unshift(current.children[1].to_s)
|
|
83
|
-
current = current.children[0]
|
|
84
|
-
end
|
|
85
|
-
parts.join("::")
|
|
86
|
-
end
|
|
87
|
-
|
|
88
63
|
def find_nested_associations(class_node, file_path, issues)
|
|
89
64
|
body = class_node.children[2]
|
|
90
65
|
return unless body
|
|
@@ -121,16 +96,6 @@ module EagerEye
|
|
|
121
96
|
end
|
|
122
97
|
end
|
|
123
98
|
|
|
124
|
-
def collect_active_storage_lines(block_body)
|
|
125
|
-
lines = Set.new
|
|
126
|
-
traverse_ast(block_body) do |node|
|
|
127
|
-
next unless node.type == :send && ACTIVE_STORAGE_METHODS.include?(node.children[1])
|
|
128
|
-
|
|
129
|
-
lines << node.loc.line
|
|
130
|
-
end
|
|
131
|
-
lines
|
|
132
|
-
end
|
|
133
|
-
|
|
134
99
|
def object_reference?(node)
|
|
135
100
|
return false unless node
|
|
136
101
|
|
|
@@ -148,10 +113,6 @@ module EagerEye
|
|
|
148
113
|
else "object"
|
|
149
114
|
end
|
|
150
115
|
end
|
|
151
|
-
|
|
152
|
-
def likely_association?(method_name)
|
|
153
|
-
HAS_MANY_ASSOCIATIONS.include?(method_name.to_s)
|
|
154
|
-
end
|
|
155
116
|
end
|
|
156
117
|
end
|
|
157
118
|
end
|
|
@@ -19,17 +19,13 @@ module EagerEye
|
|
|
19
19
|
issues.group_by(&:file_path)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def
|
|
23
|
-
|
|
22
|
+
def severity_counts
|
|
23
|
+
@severity_counts ||= issues.map(&:severity).tally
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def error_count
|
|
31
|
-
issues.count { |i| i.severity == :error }
|
|
32
|
-
end
|
|
26
|
+
def info_count = severity_counts.fetch(:info, 0)
|
|
27
|
+
def warning_count = severity_counts.fetch(:warning, 0)
|
|
28
|
+
def error_count = severity_counts.fetch(:error, 0)
|
|
33
29
|
end
|
|
34
30
|
end
|
|
35
31
|
end
|
|
@@ -73,12 +73,12 @@ module EagerEye
|
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
def summary
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
"Total: #{pluralize(issues.size, "issue")} " \
|
|
77
|
+
"(#{pluralize(error_count, "error")}, #{pluralize(warning_count, "warning")}, #{info_count} info)"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def pluralize(count, word)
|
|
81
|
+
"#{count} #{word}#{"s" unless count == 1}"
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
def colorize(text, color)
|
data/lib/eager_eye/version.rb
CHANGED
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.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- hamzagedikkaya
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-02-
|
|
11
|
+
date: 2026-02-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ast
|
|
@@ -67,6 +67,7 @@ files:
|
|
|
67
67
|
- lib/eager_eye/delegation_parser.rb
|
|
68
68
|
- lib/eager_eye/detectors/base.rb
|
|
69
69
|
- lib/eager_eye/detectors/callback_query.rb
|
|
70
|
+
- lib/eager_eye/detectors/concerns/class_inspector.rb
|
|
70
71
|
- lib/eager_eye/detectors/concerns/non_ar_source_detector.rb
|
|
71
72
|
- lib/eager_eye/detectors/count_in_iteration.rb
|
|
72
73
|
- lib/eager_eye/detectors/custom_method_query.rb
|